diff --git a/.gitignore b/.gitignore index 3542e5b..d3c2c1b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +<<<<<<< HEAD # ---> Swift # Xcode # @@ -5,60 +6,8 @@ ## User settings xcuserdata/ - -## Obj-C/Swift specific -*.hmap - -## App packaging -*.ipa -*.dSYM.zip -*.dSYM - -## Playgrounds -timeline.xctimeline -playground.xcworkspace - -# Swift Package Manager -# -# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. -# Packages/ -# Package.pins -# Package.resolved -# *.xcodeproj -# -# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata -# hence it is not needed unless you have added a package configuration file to your project -# .swiftpm - -.build/ - -# CocoaPods -# -# We recommend against adding the Pods directory to your .gitignore. However -# you should judge for yourself, the pros and cons are mentioned at: -# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control -# -# Pods/ -# -# Add this line if you want to avoid checking in source code from the Xcode workspace -# *.xcworkspace - -# Carthage -# -# Add this line if you want to avoid checking in source code from Carthage dependencies. -# Carthage/Checkouts - -Carthage/Build/ - -# fastlane -# -# It is recommended to not store the screenshots in the git repo. -# Instead, use fastlane to re-generate the screenshots whenever they are needed. -# For more information about the recommended setup visit: -# https://docs.fastlane.tools/best-practices/source-control/#source-control - -fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots/**/*.png -fastlane/test_output - +DerivedData/ +.swiftpm +.netrc +.vscode/ +**/*.swp diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..dc2a5fe --- /dev/null +++ b/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Fishee", + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser.git", .upToNextMajor(from: "1.5.0")), + .package(url: "https://github.com/duckdb/duckdb-swift", .upToNextMinor(from: .init(1, 1, 0))), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .executableTarget( + name: "Fishee", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "DuckDB", package: "duckdb-swift"), + ], + path: "Sources" + ), + .testTarget( + name: "FisheeTests", + dependencies: ["Fishee"], + path: "Tests", + resources: [ + .copy("Resources/fish_history_test.txt"), + .copy("Resources/fish_history_test_2.txt"), + ] + ) + ] +) diff --git a/Sources/Algebra.swift b/Sources/Algebra.swift new file mode 100644 index 0000000..75b883a --- /dev/null +++ b/Sources/Algebra.swift @@ -0,0 +1,41 @@ +// +// Copyright © 2024 Salar Rahmanian. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct FishHistoryEntry: Equatable { + let cmd: String + let when: Int + let paths: [String] + + func getDate() -> Date { + Date(timeIntervalSince1970: TimeInterval(when)) + } + + func writeEntry() -> [String] { + var output: [String] = [] + + output.append("- cmd: \(cmd)") + output.append(" when: \(when)") + + if !paths.isEmpty { + output.append(" paths:") + paths.forEach { output.append(" - \(String($0))") } + } + + return output + } +} diff --git a/Sources/FileHelpers.swift b/Sources/FileHelpers.swift new file mode 100644 index 0000000..42e0a94 --- /dev/null +++ b/Sources/FileHelpers.swift @@ -0,0 +1,38 @@ +// +// Copyright © 2024 Salar Rahmanian. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + + +import Foundation + + +func getPath(_ pathStr: String) -> URL? { + let userHomeDirectory = FileManager.default.homeDirectoryForCurrentUser.path + var filePath: String = pathStr + + if pathStr.hasPrefix("~") { + filePath = (pathStr as NSString).expandingTildeInPath + } + + if pathStr.hasPrefix("$HOME") { + filePath = filePath.replacingOccurrences(of: "$HOME", with: userHomeDirectory) + } + + if !FileManager.default.fileExists(atPath: filePath) { + return nil + } + + return URL(fileURLWithPath: filePath) +} diff --git a/Sources/Fishee.swift b/Sources/Fishee.swift new file mode 100644 index 0000000..4e5d7eb --- /dev/null +++ b/Sources/Fishee.swift @@ -0,0 +1,103 @@ +// +// Copyright © 2024 Salar Rahmanian. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + + +import ArgumentParser +import Foundation + +let DEFAULT_FISH_HISTORY_LOCATION: String = "~/.local/share/fish/fish_history" + +@main +struct Fishee: ParsableCommand { + @Option(name: [.short, .customLong("history-file")], help: "Location of your fish history file. Will default to ~/.local/share/fish/fish_history") + var fishHistoryLocationStr: String? + + @Option(name: .shortAndLong, help: "File path to file to merge with history file.") + var mergeFile: String? + + @Option( + name: [.short, .customLong("output-file")], + help: "File to write to. Default: same as current history file." + ) + var writeFileStr: String? + + @Flag( + name: .shortAndLong, + help: "Dry run. Will only print to the console without actually modifying the history file." + ) + var dryRun: Bool = false + + @Flag( + name: .shortAndLong, + help: "Remove duplicates from combined history. Default: false" + ) + var removeDuplicates: Bool = false + + @Flag( + name: .shortAndLong, + inversion: .prefixedNo, + help: "Backup fish history file given before writing." + ) + var backup: Bool = true + + var fishHistoryLocation: URL? { + let pathStr = fishHistoryLocationStr ?? DEFAULT_FISH_HISTORY_LOCATION + return getPath(pathStr) + } + + var writeFileLocation: URL? { + let pathStr = writeFileStr ?? DEFAULT_FISH_HISTORY_LOCATION + return getPath(pathStr) + } + + public func run() throws { + let mergeFileLocation = mergeFile.flatMap { getPath($0) } + let finalHistory: [FishHistoryEntry] = switch (fishHistoryLocation, mergeFileLocation) { + case let (fishHistoryLocation?, mergeFileLocation?): + { + let currentHistory = parseFishHistory(from: fishHistoryLocation.path) ?? [] + let toMergeHistory = parseFishHistory(from: mergeFileLocation.path) ?? [] + return mergeFishHistory(currentHistory, toMergeHistory, removeDuplicates: removeDuplicates) + }() + case let (fishHistoryLocation?, nil): + parseFishHistory(from: fishHistoryLocation.path) ?? [] + default: + [] + } + + if dryRun { + finalHistory.forEach { print("\($0.writeEntry().joined(separator: "\n"))") } + } + else { + if let writePath = writeFileLocation?.path { + let result = writeFishHistory( + to: writePath, + history: finalHistory, + historyFileLocation: fishHistoryLocation?.path, + backup: backup + ) + if result { + print("Succussfully updated \(writePath)") + } + else { + print("Failed to update \(writePath)") + } + } + } + + return + } +} diff --git a/Sources/Parser.swift b/Sources/Parser.swift new file mode 100644 index 0000000..60efe51 --- /dev/null +++ b/Sources/Parser.swift @@ -0,0 +1,131 @@ +// +// Copyright © 2024 Salar Rahmanian. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + + +import Foundation + + +func backupHistory(_ path: String) -> Bool { + let fileManager = FileManager.default + + guard fileManager.fileExists(atPath: path) else { + print("File does not exist at path: \(path)") + return false + } + + let fileURL = URL(fileURLWithPath: path) + let fileExtension = fileURL.pathExtension + let fileNameWithoutExtension = fileURL.deletingPathExtension().lastPathComponent + let directory = fileURL.deletingLastPathComponent() + + let newFileName = "\(fileNameWithoutExtension)_copy" + let newFileURL = directory.appendingPathComponent(newFileName).appendingPathExtension(fileExtension) + + do { + try fileManager.copyItem(at: fileURL, to: newFileURL) + print("File duplicated successfully to: \(newFileURL.path)") + return true + } catch { + return false + } +} + + +func writeFishHistory(to path: String, history: [FishHistoryEntry], historyFileLocation: String?, backup: Bool = true) -> Bool { + var output = "" + + if backup { + if let backupFile = historyFileLocation { + let result = backupHistory(backupFile) + if !result { + print("Failed to backup \(backupFile) so aborting!") + return false + } + } + } + + history.forEach { output += $0.writeEntry().joined(separator: "\n") + "\n" } + + if !output.isEmpty { + do { + try output.write(toFile: path, atomically: true, encoding: .utf8) + print("Successfully wrote merged history to \(path)") + return true + } catch { + print("Error writing merged history: \(error)") + return false + } + } + else { + print("Nothing to write to \(path)") + return false + } +} + +func parseFishHistory(from filePath: String) -> [FishHistoryEntry]? { + guard let fileContents = try? String(contentsOfFile: filePath) else { + print("Failed to open file.") + return nil + } + + let lines = fileContents.split(separator: "\n").map { String($0).trimmingCharacters(in: .whitespaces) } + + let initialState: (entries: [FishHistoryEntry], currentCmd: String?, currentWhen: Int?, currentPaths: [String]) = ([], nil, nil, []) + + let result = lines.reduce(into: initialState) { state, line in + if line.starts(with: "- cmd:") { + if let cmd = state.currentCmd, let when = state.currentWhen { + let entry = FishHistoryEntry(cmd: cmd, when: when, paths: state.currentPaths) + state.entries.append(entry) + state.currentPaths = [] + } + state.currentCmd = String(line.dropFirst("- cmd:".count).trimmingCharacters(in: .whitespaces)) + } else if line.starts(with: "when:") { + if let whenValue = Int(line.dropFirst("when:".count).trimmingCharacters(in: .whitespaces)) { + state.currentWhen = whenValue + } + } else if line.starts(with: "paths:") { + return + } else if line.starts(with: "- ") { + let path = String(line.dropFirst("- ".count).trimmingCharacters(in: .whitespaces)) + state.currentPaths.append(path) + } + } + + if let cmd = result.currentCmd, let when = result.currentWhen { + let entry = FishHistoryEntry(cmd: cmd, when: when, paths: result.currentPaths) + return result.entries + [entry] + } + + return result.entries +} + +func mergeFishHistory(_ left: [FishHistoryEntry], _ right: [FishHistoryEntry], removeDuplicates: Bool = false) -> [FishHistoryEntry] { + + let merged = left + right + + if removeDuplicates { + let finalList = merged.reduce(into: [String:FishHistoryEntry]()) { result, entry in + if result[entry.cmd] == nil { + result[entry.cmd] = entry + } + } + return Array(finalList.values) + } else { + return merged + } + +} diff --git a/Tests/AlgebraTest.swift b/Tests/AlgebraTest.swift new file mode 100644 index 0000000..1b9a0ac --- /dev/null +++ b/Tests/AlgebraTest.swift @@ -0,0 +1,42 @@ +// +// Copyright © 2024 Salar Rahmanian. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Testing +@testable import Fishee + +@Suite +final class AlgebraTests { + let historyItem = FishHistoryEntry(cmd: "cd Projects/Fishee/", when: 1727545693, paths: ["Projects/Fishee/"]) + + @Test func dateFromHistoryTest() { + let gotDate = historyItem.getDate() + #expect(gotDate == Date(timeIntervalSince1970: 1727545693)) + } + + @Test func writeEntryTest() { + let entry = historyItem.writeEntry() + #expect(entry.count > 0) + let expectedEntry = """ + - cmd: cd Projects/Fishee/ + when: 1727545693 + paths: + - Projects/Fishee/ + """ + #expect(entry.joined(separator: "\n") == expectedEntry) + } + +} diff --git a/Tests/FileHelpersTests.swift b/Tests/FileHelpersTests.swift new file mode 100644 index 0000000..223a5e7 --- /dev/null +++ b/Tests/FileHelpersTests.swift @@ -0,0 +1,47 @@ +// +// Copyright © 2024 Salar Rahmanian. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Testing +@testable import Fishee + + +final class FileHelpersTests { + let filePath = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("myfile.txt") + + init() { + try? "this is a test".write( + to: filePath, + atomically: true, + encoding: .utf8 + ) + } + + deinit { + try? FileManager.default.removeItem(at: filePath) + } + + @Test(arguments: [ + "$HOME/myfile.txt", + "~/myfile.txt", + "\(FileManager.default.homeDirectoryForCurrentUser.path)/myfile.txt" + ]) + func getPathTest(testPath: String) { + let path = getPath(testPath) + let expected = URL(fileURLWithPath: "\(FileManager.default.homeDirectoryForCurrentUser.path)/myfile.txt") + #expect(path == expected) + } +} diff --git a/Tests/ParserTests.swift b/Tests/ParserTests.swift new file mode 100644 index 0000000..87f3891 --- /dev/null +++ b/Tests/ParserTests.swift @@ -0,0 +1,90 @@ +// +// Copyright © 2024 Salar Rahmanian. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Testing +@testable import Fishee + +@Suite +final class ParserTests { + let fishHistoryFile = Bundle.module.path(forResource: "fish_history_test", ofType: "txt") + let historyItem = FishHistoryEntry(cmd: "cd Projects/Fishee/", when: 1727545693, paths: ["Projects/Fishee/"]) + let historyItem2 = FishHistoryEntry(cmd: "swift package tools-version", when: 1727545709, paths: []) + let filePathforWriteTest = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0].appendingPathComponent( + "myfile.txt" + ) + let filePathforFileBackupTest = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0].appendingPathComponent( + "myfile.txt_copy" + ) + + deinit { + if FileManager.default.fileExists(atPath: filePathforWriteTest.path) { + _ = try? FileManager.default.removeItem(at: filePathforWriteTest) + } + if FileManager.default.fileExists(atPath: filePathforFileBackupTest.path) { + _ = try? FileManager.default.removeItem(at: filePathforFileBackupTest) + } + } + + @Test func parseFishHistoryTest() { + #expect(fishHistoryFile != nil) + let fishHistory = parseFishHistory(from: fishHistoryFile!) + #expect(fishHistory!.count > 0) + let expectedHistory = [historyItem, historyItem2] + #expect(fishHistory == expectedHistory) + } + + @Test func writeFishHistoryTest() { + let written = writeFishHistory( + to: filePathforWriteTest.path, + history: [historyItem], + historyFileLocation: fishHistoryFile + ) + #expect(written) + + let fileContent = try? String(contentsOf: filePathforWriteTest, encoding: .utf8) + let expectedEntry = """ + - cmd: cd Projects/Fishee/ + when: 1727545693 + paths: + - Projects/Fishee/ + + """ + #expect(fileContent == expectedEntry) + #expect(FileManager.default.fileExists(atPath: filePathforFileBackupTest.path)) + } + + @Test func mergeFishHistoryTest() { + let merged = mergeFishHistory([historyItem], [historyItem2]) + #expect(merged.count == 2) + #expect(merged.contains(historyItem)) + #expect(merged.contains(historyItem2)) + } + + @Test func mergeFishHistoryWithDuplicateTest() { + let merged = mergeFishHistory([historyItem], [historyItem, historyItem2]) + #expect(merged.count == 3) + #expect(merged.contains(historyItem)) + #expect(merged.contains(historyItem2)) + } + + @Test func mergeFishHistoryRemoveDuplicateTest() { + let merged = mergeFishHistory([historyItem], [historyItem, historyItem2], removeDuplicates: true) + #expect(merged.count == 2) + #expect(merged.contains(historyItem)) + #expect(merged.contains(historyItem2)) + } +} diff --git a/Tests/Resources/fish_history_test.txt b/Tests/Resources/fish_history_test.txt new file mode 100644 index 0000000..a007c1e --- /dev/null +++ b/Tests/Resources/fish_history_test.txt @@ -0,0 +1,7 @@ +- cmd: cd Projects/Fishee/ + when: 1727545693 + paths: + - Projects/Fishee/ +- cmd: swift package tools-version + when: 1727545709 + diff --git a/Tests/Resources/fish_history_test_2.txt b/Tests/Resources/fish_history_test_2.txt new file mode 100644 index 0000000..0dddc9f --- /dev/null +++ b/Tests/Resources/fish_history_test_2.txt @@ -0,0 +1,6 @@ +- cmd: gh pr create + when: 1727024167 +- cmd: nix flake update + when: 1727140536 +- cmd: swift package tools-version + when: 1727545709