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)!
+ }
+}