diff --git a/CHANGELOG.md b/CHANGELOG.md index 741de3d..29bde15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to Forel are documented here. Format loosely follows ### Added - Added an Open Application action that launches a chosen app, with an option to pass the matched file to it. - Watcher notifications: a new toggle in Settings lets you enable or disable system notifications when automatic rules process files. +- Move to Folder, Copy to Folder, and Rename actions now support {year}, {month}, and {day} tokens — e.g. a destination of Backup/{year}-{month} automatically sorts files into monthly folders, creating them as needed. ### Changed - Settings now opens in its own window, with a "Settings…" item under the Forel menu and the standard ⌘, shortcut, instead of being a pane inside the main window. diff --git a/Sources/ForelApp/Views/RuleEditorView.swift b/Sources/ForelApp/Views/RuleEditorView.swift index efe3d29..96fb536 100644 --- a/Sources/ForelApp/Views/RuleEditorView.swift +++ b/Sources/ForelApp/Views/RuleEditorView.swift @@ -820,7 +820,7 @@ private struct ActionRow: View { @ViewBuilder private var actionParams: some View { switch action.kind { case .moveToFolder, .copyToFolder: - FolderField(placeholder: "Destination folder", path: paramBinding(ActionParam.destination)) + DestinationFolderField(path: paramBinding(ActionParam.destination)) case .rename: RenamePatternEditor(pattern: paramBinding(ActionParam.pattern), cleanFileName: action.params[ActionParam.cleanFileName]?.boolValue == true) case .addTag, .removeTag: @@ -1290,6 +1290,122 @@ private struct TagTokensEditor: View { } } +/// An editable destination path for Move/Copy actions, with a "Choose…" +/// button for picking a literal base folder and token chips for embedding +/// dynamic subfolders — e.g. `{year}-{month}` under a chosen base resolves +/// to `.../2026-07`, auto-created on the way in (`ActionExecutor` already +/// creates any missing destination folder). +private struct DestinationFolderField: View { + @Binding var path: String + + private let tokens: [(placeholder: String, label: String)] = [ + ("{year}", "year"), + ("{month}", "month"), + ("{day}", "day"), + ("{current_date}", "current date"), + ] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + GlassField(placeholder: "Destination folder, e.g. ~/Downloads/Backup/{year}-{month}", text: $path) + Button("Choose…", action: choose).buttonStyle(SecondaryButtonStyle()) + } + + if hasDestination { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(tokens, id: \.placeholder) { token in + tokenButton(token) + } + Spacer(minLength: 0) + } + } + } + + if !preview.isEmpty { + Text(preview) + .font(.system(size: 11)) + .foregroundStyle(ForelTheme.secondaryText) + } + } + } + + private var preview: String { + let candidate = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !candidate.isEmpty else { return "" } + return "Preview: \(TokenExpander.previewExpand(candidate))" + } + + /// A token chip appended to an empty destination would leave a bare + /// `{year}` with no base folder to sit under, which isn't a usable + /// path — chips only make sense once a destination has been chosen or + /// typed. + private var hasDestination: Bool { + !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private func choose() { + if let chosen = FolderPicker.choose(startingAt: path) { + path = chosen + } + } + + private func tokenButton(_ token: (placeholder: String, label: String)) -> some View { + let isActive = path.contains(token.placeholder) + return Button { + toggle(token.placeholder) + } label: { + Text(token.label) + .font(.system(size: 12, weight: .medium)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Capsule().fill(isActive ? ForelTheme.accent.opacity(0.22) : ForelTheme.surface)) + .overlay(Capsule().strokeBorder(isActive ? ForelTheme.accent : ForelTheme.surfaceBorder)) + .foregroundStyle(isActive ? ForelTheme.accent : ForelTheme.secondaryText) + } + .buttonStyle(.plain) + } + + /// Chips join with `/` (a new path component) rather than `-`, matching + /// what makes sense for a *folder path* — unlike `RenamePatternEditor`'s + /// chips, which join with `-` for a single filename segment. + private func toggle(_ token: String) { + if path.contains(token) { + path = removePathToken(token, from: path) + } else { + insert(token) + } + } + + private func insert(_ token: String) { + guard !path.contains(token) else { return } + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + path = token + } else { + path = "\(trimmed)\(trimmed.hasSuffix("/") ? "" : "/")\(token)" + } + } + + private func removePathToken(_ token: String, from value: String) -> String { + var updated = value.replacingOccurrences(of: token, with: "") + while updated.contains("//") { + updated = updated.replacingOccurrences(of: "//", with: "/") + } + while updated.contains("--") { + updated = updated.replacingOccurrences(of: "--", with: "-") + } + updated = updated + .replacingOccurrences(of: "/-", with: "/") + .replacingOccurrences(of: "-/", with: "/") + if updated.count > 1, updated.hasSuffix("/") { + updated.removeLast() + } + return updated.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + private struct RenamePatternEditor: View { @Binding var pattern: String let cleanFileName: Bool @@ -1300,6 +1416,9 @@ private struct RenamePatternEditor: View { ("{date_modified}", "date modified"), ("{date_created}", "date created"), ("{current_date}", "current date"), + ("{year}", "year"), + ("{month}", "month"), + ("{day}", "day"), ("{size}", "size"), ] @@ -1316,22 +1435,21 @@ private struct RenamePatternEditor: View { } } - Text(preview) - .font(.system(size: 11)) - .foregroundStyle(ForelTheme.secondaryText) + if !preview.isEmpty { + Text(preview) + .font(.system(size: 11)) + .foregroundStyle(ForelTheme.secondaryText) + } } } private var preview: String { let candidate = pattern.trimmingCharacters(in: .whitespacesAndNewlines) - guard !candidate.isEmpty else { return "Preview: " } + guard !candidate.isEmpty else { return "" } if candidate.contains("/") { return "⚠️ Pattern contains '/' which is not allowed in filenames" } - let previewName = candidate - .replacingOccurrences(of: "{name}", with: "file") - .replacingOccurrences(of: "{extension}", with: "txt") - .replacingOccurrences(of: "{current_date}", with: dateString()) + let previewName = TokenExpander.previewExpand(candidate) let displayName = cleanFileName ? ActionExecutor.cleanFileName(previewName) : previewName if displayName == "." || displayName == ".." { return "⚠️ Pattern resolves to '\(displayName)' which is not a valid filename" @@ -1345,16 +1463,10 @@ private struct RenamePatternEditor: View { return "Preview: \(displayName)" } - private func dateString() -> String { - let f = DateFormatter() - f.dateFormat = "yyyy-MM-dd" - return f.string(from: Date()) - } - private func tokenButton(_ token: (placeholder: String, label: String)) -> some View { let isActive = pattern.contains(token.placeholder) return Button { - insert(token.placeholder) + toggle(token.placeholder) } label: { Text(token.label) .font(.system(size: 12, weight: .medium)) @@ -1367,6 +1479,14 @@ private struct RenamePatternEditor: View { .buttonStyle(.plain) } + private func toggle(_ token: String) { + if pattern.contains(token) { + pattern = removePatternToken(token, from: pattern) + } else { + insert(token) + } + } + private func insert(_ token: String) { guard !pattern.contains(token) else { return } let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1376,6 +1496,23 @@ private struct RenamePatternEditor: View { pattern = "\(trimmed)\(trimmed.hasSuffix("-") || trimmed.hasSuffix(".") ? "" : "-")\(token)" } } + + private func removePatternToken(_ token: String, from value: String) -> String { + var updated = value.replacingOccurrences(of: token, with: "") + while updated.contains("--") { + updated = updated.replacingOccurrences(of: "--", with: "-") + } + updated = updated + .replacingOccurrences(of: "-.", with: ".") + .replacingOccurrences(of: ".-", with: ".") + if updated.hasPrefix("-") || updated.hasPrefix(".") { + updated.removeFirst() + } + if updated.hasSuffix("-") || updated.hasSuffix(".") { + updated.removeLast() + } + return updated.trimmingCharacters(in: .whitespacesAndNewlines) + } } private enum DateValueFormatter { diff --git a/Sources/ForelCore/Engine/ActionExecutor.swift b/Sources/ForelCore/Engine/ActionExecutor.swift index ca8da14..1db762a 100644 --- a/Sources/ForelCore/Engine/ActionExecutor.swift +++ b/Sources/ForelCore/Engine/ActionExecutor.swift @@ -185,7 +185,7 @@ public enum ActionExecutor { public static func execute(_ action: Action, path: String) throws -> Applied { switch action.kind { case .moveToFolder: - let destDir = try stringParam(action, ActionParam.destination, "MoveToFolder") + let destDir = try expandedDestination(action, path: path, "MoveToFolder") return try moveIntoDir(path: path, destDir: destDir, resolution: conflictResolution(action)) case .copyToFolder: return try copyToFolder(action, path: path) @@ -222,6 +222,15 @@ public enum ActionExecutor { return value } + /// Resolves a Move/Copy action's destination folder, substituting any + /// `{year}`/`{month}`/`{day}`/etc. tokens against `path` via + /// `TokenExpander` — e.g. `.../Backup/{year}-{month}` becomes + /// `.../Backup/2026-07`. + private static func expandedDestination(_ action: Action, path: String, _ kind: String) throws -> String { + let raw = try stringParam(action, ActionParam.destination, kind) + return try TokenExpander.expand(raw, path: path) + } + /// Moves `path` into `destDir` (created if needed). `resolution` decides /// what happens if a file with the same name is already there (see /// `resolveDestination`). Trash/delete have no user-facing conflict @@ -236,7 +245,7 @@ public enum ActionExecutor { } private static func copyToFolder(_ action: Action, path: String) throws -> Applied { - let destDir = try stringParam(action, ActionParam.destination, "CopyToFolder") + let destDir = try expandedDestination(action, path: path, "CopyToFolder") try FileManager.default.createDirectory(atPath: destDir, withIntermediateDirectories: true) let fileName = (path as NSString).lastPathComponent let naiveDest = (destDir as NSString).appendingPathComponent(fileName) @@ -871,7 +880,8 @@ public enum ActionExecutor { switch action.kind { case .moveToFolder: - let destDir = action.params[ActionParam.destination]?.stringValue ?? "" + let rawDestDir = action.params[ActionParam.destination]?.stringValue ?? "" + let destDir = try TokenExpander.expand(rawDestDir, path: path) // A file already directly in its destination folder is a no-op, // never a rename-to-avoid-itself collision — and since nothing @@ -922,7 +932,8 @@ public enum ActionExecutor { isTerminal: true ) case .copyToFolder: - let destDir = action.params[ActionParam.destination]?.stringValue ?? "" + let rawDestDir = action.params[ActionParam.destination]?.stringValue ?? "" + let destDir = try TokenExpander.expand(rawDestDir, path: path) let naiveTarget = (destDir as NSString).appendingPathComponent(fileName) let resolution = conflictResolution(action) let conflicts = FileManager.default.fileExists(atPath: naiveTarget) @@ -1312,44 +1323,12 @@ public enum ActionExecutor { (path as NSString).standardizingPath } - private static func formatFileSize(_ bytes: UInt64) -> String { - let kb: Double = 1024 - let mb = 1024 * kb - let gb = 1024 * mb - let value = Double(bytes) - if value >= gb { return String(format: "%.1fGB", value / gb) } - if value >= mb { return String(format: "%.1fMB", value / mb) } - if value >= kb { return String(format: "%.1fKB", value / kb) } - return "\(bytes)B" - } - - /// Substitutes tokens in rename patterns. Supported tokens: `{name}`, - /// `{extension}`, `{date_created}`, `{date_modified}`, `{current_date}`, `{size}`. + /// Substitutes tokens in rename patterns via `TokenExpander`. Supported + /// tokens: `{name}`, `{extension}`, `{current_date}`, `{year}`, + /// `{month}`, `{day}`, `{date_created}`, `{date_modified}`, `{size}`. private static func applyRenamePattern(_ pattern: String, path: String) throws -> String { - let url = URL(fileURLWithPath: path) - let stem = url.deletingPathExtension().lastPathComponent - let ext = url.pathExtension - let today = Date() - - let dayFormatter = DateFormatter() - dayFormatter.dateFormat = "yyyy-MM-dd" - dayFormatter.timeZone = .current - - var result = pattern - .replacingOccurrences(of: "{name}", with: stem) - .replacingOccurrences(of: "{extension}", with: ext) - .replacingOccurrences(of: "{current_date}", with: dayFormatter.string(from: today)) - - if result.contains("{date_modified}") || result.contains("{date_created}") || result.contains("{size}") { - let attrs = try FileManager.default.attributesOfItem(atPath: path) - let modified = (attrs[.modificationDate] as? Date) ?? Date() - let created = (attrs[.creationDate] as? Date) ?? Date() - let size = (attrs[.size] as? UInt64) ?? 0 - result = result - .replacingOccurrences(of: "{date_modified}", with: dayFormatter.string(from: modified)) - .replacingOccurrences(of: "{date_created}", with: dayFormatter.string(from: created)) - .replacingOccurrences(of: "{size}", with: formatFileSize(size)) - } + let ext = URL(fileURLWithPath: path).pathExtension + var result = try TokenExpander.expand(pattern, path: path) if result.isEmpty { throw ActionError("rename pattern produced empty filename") diff --git a/Sources/ForelCore/Engine/TokenExpander.swift b/Sources/ForelCore/Engine/TokenExpander.swift new file mode 100644 index 0000000..6eacb63 --- /dev/null +++ b/Sources/ForelCore/Engine/TokenExpander.swift @@ -0,0 +1,95 @@ +// Forel - A native macOS file-automation app +// Copyright (C) 2026 Lab421 +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import Foundation + +/// Token substitution shared by the Rename action's pattern and the +/// Move/Copy actions' destination path — e.g. `{year}-{month}` for a +/// monthly archive folder, or `{name}-{current_date}.{extension}` for a +/// renamed file. +public enum TokenExpander { + /// Substitutes tokens against a real file at `path`: `{name}`, + /// `{extension}`, `{current_date}`, `{year}`, `{month}`, `{day}`, + /// `{date_modified}`, `{date_created}`, `{size}`. The last three read + /// the file's actual attributes and require it to exist; every other + /// token only needs `path` for its name/extension and doesn't touch the + /// disk. `now` is the moment of evaluation (rule run or Dry Run) — the + /// date tokens use it, not any date embedded in the file itself. + public static func expand(_ template: String, path: String, now: Date = Date()) throws -> String { + let url = URL(fileURLWithPath: path) + let stem = url.deletingPathExtension().lastPathComponent + let ext = url.pathExtension + + var result = substituteCommonTokens(template, name: stem, extension: ext, now: now) + + if result.contains("{date_modified}") || result.contains("{date_created}") || result.contains("{size}") { + let attrs = try FileManager.default.attributesOfItem(atPath: path) + let modified = (attrs[.modificationDate] as? Date) ?? now + let created = (attrs[.creationDate] as? Date) ?? now + let size = (attrs[.size] as? UInt64) ?? 0 + result = result + .replacingOccurrences(of: "{date_modified}", with: dayFormatter.string(from: modified)) + .replacingOccurrences(of: "{date_created}", with: dayFormatter.string(from: created)) + .replacingOccurrences(of: "{size}", with: formatFileSize(size)) + } + + return result + } + + /// Same token set with placeholder values (a fixed sample name/extension + /// and size, `now`'s date, no disk access, never throws) — for live + /// previews in the rule editor before a concrete file exists to + /// evaluate against. + public static func previewExpand(_ template: String, now: Date = Date()) -> String { + substituteCommonTokens(template, name: "file", extension: "txt", now: now) + .replacingOccurrences(of: "{date_modified}", with: dayFormatter.string(from: now)) + .replacingOccurrences(of: "{date_created}", with: dayFormatter.string(from: now)) + .replacingOccurrences(of: "{size}", with: "1.2MB") + } + + private static func substituteCommonTokens(_ template: String, name: String, extension ext: String, now: Date) -> String { + template + .replacingOccurrences(of: "{name}", with: name) + .replacingOccurrences(of: "{extension}", with: ext) + .replacingOccurrences(of: "{current_date}", with: dayFormatter.string(from: now)) + .replacingOccurrences(of: "{year}", with: yearFormatter.string(from: now)) + .replacingOccurrences(of: "{month}", with: monthFormatter.string(from: now)) + .replacingOccurrences(of: "{day}", with: dayOfMonthFormatter.string(from: now)) + } + + private static func formatFileSize(_ bytes: UInt64) -> String { + let kb: Double = 1024 + let mb = 1024 * kb + let gb = 1024 * mb + let value = Double(bytes) + if value >= gb { return String(format: "%.1fGB", value / gb) } + if value >= mb { return String(format: "%.1fMB", value / mb) } + if value >= kb { return String(format: "%.1fKB", value / kb) } + return "\(bytes)B" + } + + private static var dayFormatter: DateFormatter { formatter("yyyy-MM-dd") } + private static var yearFormatter: DateFormatter { formatter("yyyy") } + private static var monthFormatter: DateFormatter { formatter("MM") } + private static var dayOfMonthFormatter: DateFormatter { formatter("dd") } + + private static func formatter(_ dateFormat: String) -> DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = dateFormat + formatter.timeZone = .current + return formatter + } +} diff --git a/Tests/ForelCoreTests/ActionExecutorTests.swift b/Tests/ForelCoreTests/ActionExecutorTests.swift index b57d736..359e515 100644 --- a/Tests/ForelCoreTests/ActionExecutorTests.swift +++ b/Tests/ForelCoreTests/ActionExecutorTests.swift @@ -65,6 +65,83 @@ import Foundation #expect(!FileManager.default.fileExists(atPath: (dir.path as NSString).appendingPathComponent("report-archived.txt.txt"))) } + @Test func renamePatternSupportsYearMonthDayTokens() throws { + let dir = TempDir() + let file = dir.file("report.txt", contents: "hello") + let rename = makeAction(.rename, .object(["pattern": .string("archive-{year}-{month}-{day}.{extension}")])) + + _ = try ActionExecutor.execute(rename, path: file) + + let expected = expandedNow("archive-{year}-{month}-{day}.txt") + #expect(FileManager.default.fileExists(atPath: (dir.path as NSString).appendingPathComponent(expected))) + } + + @Test func moveToFolderExpandsDateTokensInDestinationAndCreatesMissingSubfolders() throws { + let dir = TempDir() + let file = dir.file("receipt.pdf", contents: "hello") + let destinationTemplate = (dir.path as NSString).appendingPathComponent("Backup/{year}-{month}") + let move = makeAction(.moveToFolder, .object(["destination": .string(destinationTemplate)])) + + let applied = try ActionExecutor.execute(move, path: file) + + let expectedDir = (dir.path as NSString).appendingPathComponent("Backup/\(expandedNow("{year}-{month}"))") + #expect(applied.newPath == (expectedDir as NSString).appendingPathComponent("receipt.pdf")) + #expect(FileManager.default.fileExists(atPath: applied.newPath)) + var isDirectory: ObjCBool = false + #expect(FileManager.default.fileExists(atPath: expectedDir, isDirectory: &isDirectory)) + #expect(isDirectory.boolValue) + } + + @Test func copyToFolderExpandsDateTokensInDestination() throws { + let dir = TempDir() + let file = dir.file("receipt.pdf", contents: "hello") + let destinationTemplate = (dir.path as NSString).appendingPathComponent("Backup/{year}-{month}-{day}") + let copy = makeAction(.copyToFolder, .object(["destination": .string(destinationTemplate)])) + + let applied = try ActionExecutor.execute(copy, path: file) + + let expectedDir = (dir.path as NSString).appendingPathComponent("Backup/\(expandedNow("{year}-{month}-{day}"))") + let expectedCopy = (expectedDir as NSString).appendingPathComponent("receipt.pdf") + #expect(applied.copiedPath == expectedCopy) + #expect(FileManager.default.fileExists(atPath: expectedCopy)) + // The original stays put — unlike Move, Copy never takes the source + // file out of its watched folder. + #expect(FileManager.default.fileExists(atPath: file)) + } + + @Test func dryRunPlanShowsTheExpandedDestinationNotTheRawTemplate() throws { + let dir = TempDir() + let file = dir.file("receipt.pdf", contents: "hello") + let destinationTemplate = (dir.path as NSString).appendingPathComponent("Backup/{year}-{month}") + let move = makeAction(.moveToFolder, .object(["destination": .string(destinationTemplate)])) + + let plan = try ActionExecutor.plan(move, path: file) + + let expectedDir = (dir.path as NSString).appendingPathComponent("Backup/\(expandedNow("{year}-{month}"))") + let expectedTarget = (expectedDir as NSString).appendingPathComponent("receipt.pdf") + #expect(plan.targetPath == expectedTarget) + #expect(!plan.description.contains("{year}")) + #expect(!plan.description.contains("{month}")) + } + + /// Expands `{year}`/`{month}`/`{day}` against the current date the same + /// way `TokenExpander` does in production (no injectable clock in + /// `ActionExecutor`), so these assertions match what the code under test + /// actually computed rather than a value frozen at write time. + private func expandedNow(_ template: String) -> String { + let formatter = DateFormatter() + formatter.timeZone = .current + let now = Date() + var result = template + formatter.dateFormat = "yyyy" + result = result.replacingOccurrences(of: "{year}", with: formatter.string(from: now)) + formatter.dateFormat = "MM" + result = result.replacingOccurrences(of: "{month}", with: formatter.string(from: now)) + formatter.dateFormat = "dd" + result = result.replacingOccurrences(of: "{day}", with: formatter.string(from: now)) + return result + } + @Test func uncompressZipExtractsSingleRootItemAndMovesArchiveAway() throws { let dir = TempDir() let staging = dir.dir("staging") diff --git a/Tests/ForelCoreTests/Engine/TokenExpanderTests.swift b/Tests/ForelCoreTests/Engine/TokenExpanderTests.swift new file mode 100644 index 0000000..b18b392 --- /dev/null +++ b/Tests/ForelCoreTests/Engine/TokenExpanderTests.swift @@ -0,0 +1,70 @@ +// Forel - A native macOS file-automation app +// Copyright (C) 2026 Lab421 +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import Testing +import Foundation +@testable import ForelCore + +struct TokenExpanderTests { + @Test func expandsYearMonthDayTokens() throws { + let dir = TempDir() + let file = dir.file("report.txt") + + let result = try TokenExpander.expand("{year}-{month}-{day}", path: file, now: fixedDate()) + + #expect(result == "2026-03-05") + } + + @Test func leavesUnknownTokensUntouched() throws { + let dir = TempDir() + let file = dir.file("report.txt") + + let result = try TokenExpander.expand("{not_a_token}", path: file) + + #expect(result == "{not_a_token}") + } + + @Test func expandsNameAndExtensionWithoutRequiringTheFileToExist() throws { + // {name}/{extension} only need `path` itself, not a real file on + // disk — unlike {date_modified}/{date_created}/{size} below. + let result = try TokenExpander.expand("{name}.{extension}", path: "/nonexistent/report.pdf") + + #expect(result == "report.pdf") + } + + @Test func throwsWhenASizeOrDateAttributeTokenNeedsAMissingFile() { + #expect(throws: (any Error).self) { + try TokenExpander.expand("{size}", path: "/nonexistent/report.pdf") + } + } + + @Test func previewExpandNeverThrowsAndUsesPlaceholderValues() { + let result = TokenExpander.previewExpand("{name}-{year}-{month}-{day}.{extension}", now: fixedDate()) + + #expect(result == "file-2026-03-05.txt") + } + + private func fixedDate() -> Date { + var components = DateComponents() + components.year = 2026 + components.month = 3 + components.day = 5 + components.hour = 12 + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + return calendar.date(from: components)! + } +}