From 689fd167fba8277f070938df1aeac3f24c528d5d Mon Sep 17 00:00:00 2001 From: Ashton Meuser Date: Wed, 24 Sep 2025 13:12:04 -0700 Subject: [PATCH 1/6] Enable pruning downloaded models --- Sources/LocalLLMClientCore/LLMSession.swift | 22 +++++++++++++++++++++ Sources/LocalLLMClientUtility/URL+.swift | 13 ++++++++++++ 2 files changed, 35 insertions(+) diff --git a/Sources/LocalLLMClientCore/LLMSession.swift b/Sources/LocalLLMClientCore/LLMSession.swift index f14fe52..31dc8f9 100644 --- a/Sources/LocalLLMClientCore/LLMSession.swift +++ b/Sources/LocalLLMClientCore/LLMSession.swift @@ -302,6 +302,28 @@ public extension LLMSession { public func downloadModel(onProgress: @Sendable @escaping (Double) async -> Void = { _ in }) async throws { try await downloader.download(onProgress: onProgress) } + + public static func pruneModels(in url: URL = FileDownloader.defaultRootDestination, excluding: [Model] = []) throws { + guard let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsPackageDescendants]) else { return } + + let excludingNormalized: [URL] = excluding.compactMap { + let model = $0 as? Self + return model?.modelPath.resolvingSymlinksInPath().standardizedFileURL + } + + for case let fileURL as URL in enumerator { + if excludingNormalized.contains(fileURL.resolvingSymlinksInPath().standardizedFileURL) { + enumerator.skipDescendants() + continue + } + + if (try? fileURL.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == false { + try FileManager.default.removeItem(at: fileURL) + } + } + + try url.removeEmptyFolders() + } } struct LocalModel: Model { diff --git a/Sources/LocalLLMClientUtility/URL+.swift b/Sources/LocalLLMClientUtility/URL+.swift index aaec4ee..b5e6605 100644 --- a/Sources/LocalLLMClientUtility/URL+.swift +++ b/Sources/LocalLLMClientUtility/URL+.swift @@ -18,4 +18,17 @@ package extension URL { return url #endif } + + func removeEmptyFolders() throws { + guard isFileURL else { return } + + let contents = try FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: [.isDirectoryKey]) + let subdirectories = contents.filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true } + + for subdirectory in subdirectories { try subdirectory.removeEmptyFolders() } + + guard try FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil).isEmpty else { return } + + try FileManager.default.removeItem(at: self) + } } From 2f8c984015d8374621c387262b5fbc37281b6cbc Mon Sep 17 00:00:00 2001 From: Ashton Meuser Date: Wed, 24 Sep 2025 14:59:48 -0700 Subject: [PATCH 2/6] Allow LocalModel to be excluded from prune --- Sources/LocalLLMClientCore/LLMSession.swift | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Sources/LocalLLMClientCore/LLMSession.swift b/Sources/LocalLLMClientCore/LLMSession.swift index 31dc8f9..37a1778 100644 --- a/Sources/LocalLLMClientCore/LLMSession.swift +++ b/Sources/LocalLLMClientCore/LLMSession.swift @@ -306,10 +306,15 @@ public extension LLMSession { public static func pruneModels(in url: URL = FileDownloader.defaultRootDestination, excluding: [Model] = []) throws { guard let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsPackageDescendants]) else { return } - let excludingNormalized: [URL] = excluding.compactMap { - let model = $0 as? Self - return model?.modelPath.resolvingSymlinksInPath().standardizedFileURL - } + let excludingNormalized: Set = Set(excluding.compactMap { model -> URL? in + if let model = model as? LLMSession.DownloadModel { + return model.modelPath.resolvingSymlinksInPath().standardizedFileURL + } + if let model = model as? LLMSession.LocalModel { + return model.modelPath.resolvingSymlinksInPath().standardizedFileURL + } + return nil + }) for case let fileURL as URL in enumerator { if excludingNormalized.contains(fileURL.resolvingSymlinksInPath().standardizedFileURL) { From 1256c615a1ba97ee96e6df56f2fe0653da33b481 Mon Sep 17 00:00:00 2001 From: Ashton Meuser Date: Wed, 24 Sep 2025 15:04:15 -0700 Subject: [PATCH 3/6] PR suggestions --- Sources/LocalLLMClientCore/LLMSession.swift | 2 +- Sources/LocalLLMClientUtility/URL+.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/LocalLLMClientCore/LLMSession.swift b/Sources/LocalLLMClientCore/LLMSession.swift index 37a1778..07e089c 100644 --- a/Sources/LocalLLMClientCore/LLMSession.swift +++ b/Sources/LocalLLMClientCore/LLMSession.swift @@ -322,7 +322,7 @@ public extension LLMSession { continue } - if (try? fileURL.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == false { + if (try fileURL.resourceValues(forKeys: [.isDirectoryKey])).isDirectory == false { try FileManager.default.removeItem(at: fileURL) } } diff --git a/Sources/LocalLLMClientUtility/URL+.swift b/Sources/LocalLLMClientUtility/URL+.swift index b5e6605..b03f371 100644 --- a/Sources/LocalLLMClientUtility/URL+.swift +++ b/Sources/LocalLLMClientUtility/URL+.swift @@ -23,7 +23,7 @@ package extension URL { guard isFileURL else { return } let contents = try FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: [.isDirectoryKey]) - let subdirectories = contents.filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true } + let subdirectories = try contents.filter { try $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false } for subdirectory in subdirectories { try subdirectory.removeEmptyFolders() } From 28999b25a473dce13f2f8ca75223e0d7b487ee17 Mon Sep 17 00:00:00 2001 From: Ashton Meuser Date: Thu, 25 Sep 2025 08:29:27 -0700 Subject: [PATCH 4/6] Update signature --- Sources/LocalLLMClientCore/LLMSession.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/LocalLLMClientCore/LLMSession.swift b/Sources/LocalLLMClientCore/LLMSession.swift index 07e089c..1d0e9c1 100644 --- a/Sources/LocalLLMClientCore/LLMSession.swift +++ b/Sources/LocalLLMClientCore/LLMSession.swift @@ -303,10 +303,13 @@ public extension LLMSession { try await downloader.download(onProgress: onProgress) } - public static func pruneModels(in url: URL = FileDownloader.defaultRootDestination, excluding: [Model] = []) throws { + public static func removeAllModels( + in url: URL = FileDownloader.defaultRootDestination, + excludingModels: [any Model] = [] + ) throws { guard let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsPackageDescendants]) else { return } - let excludingNormalized: Set = Set(excluding.compactMap { model -> URL? in + let excludingNormalized: Set = Set(excludingModels.compactMap { model -> URL? in if let model = model as? LLMSession.DownloadModel { return model.modelPath.resolvingSymlinksInPath().standardizedFileURL } From fc303c651dfd6c2e46253e6376ef023129ed0977 Mon Sep 17 00:00:00 2001 From: Ashton Meuser Date: Thu, 25 Sep 2025 08:31:19 -0700 Subject: [PATCH 5/6] Move empty directory pruning logic --- Sources/LocalLLMClientCore/LLMSession.swift | 2 +- Sources/LocalLLMClientUtility/FileManager+.swift | 16 ++++++++++++++++ Sources/LocalLLMClientUtility/URL+.swift | 13 ------------- 3 files changed, 17 insertions(+), 14 deletions(-) create mode 100644 Sources/LocalLLMClientUtility/FileManager+.swift diff --git a/Sources/LocalLLMClientCore/LLMSession.swift b/Sources/LocalLLMClientCore/LLMSession.swift index 1d0e9c1..c4cd6c7 100644 --- a/Sources/LocalLLMClientCore/LLMSession.swift +++ b/Sources/LocalLLMClientCore/LLMSession.swift @@ -330,7 +330,7 @@ public extension LLMSession { } } - try url.removeEmptyFolders() + try FileManager.default.removeEmptyDirectories(in: url) } } diff --git a/Sources/LocalLLMClientUtility/FileManager+.swift b/Sources/LocalLLMClientUtility/FileManager+.swift new file mode 100644 index 0000000..56dfd6f --- /dev/null +++ b/Sources/LocalLLMClientUtility/FileManager+.swift @@ -0,0 +1,16 @@ +import Foundation + +package extension FileManager { + func removeEmptyDirectories(in url: URL) throws { + guard url.isFileURL else { return } + + let contents = try contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey]) + let subdirectories = try contents.filter { try $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false } + + for subdirectory in subdirectories { try removeEmptyDirectories(in: subdirectory) } + + guard try contentsOfDirectory(at: url, includingPropertiesForKeys: nil).isEmpty else { return } + + try removeItem(at: url) + } +} diff --git a/Sources/LocalLLMClientUtility/URL+.swift b/Sources/LocalLLMClientUtility/URL+.swift index b03f371..aaec4ee 100644 --- a/Sources/LocalLLMClientUtility/URL+.swift +++ b/Sources/LocalLLMClientUtility/URL+.swift @@ -18,17 +18,4 @@ package extension URL { return url #endif } - - func removeEmptyFolders() throws { - guard isFileURL else { return } - - let contents = try FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: [.isDirectoryKey]) - let subdirectories = try contents.filter { try $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false } - - for subdirectory in subdirectories { try subdirectory.removeEmptyFolders() } - - guard try FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil).isEmpty else { return } - - try FileManager.default.removeItem(at: self) - } } From 857256ee2f6a7a477113715cf768ea1fc1e96289 Mon Sep 17 00:00:00 2001 From: Ashton Meuser Date: Thu, 25 Sep 2025 08:39:37 -0700 Subject: [PATCH 6/6] Move core logic to FIleManager extension --- Sources/LocalLLMClientCore/LLMSession.swift | 27 +++++-------------- .../LocalLLMClientUtility/FileManager+.swift | 20 ++++++++++++++ 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/Sources/LocalLLMClientCore/LLMSession.swift b/Sources/LocalLLMClientCore/LLMSession.swift index c4cd6c7..5937c9e 100644 --- a/Sources/LocalLLMClientCore/LLMSession.swift +++ b/Sources/LocalLLMClientCore/LLMSession.swift @@ -307,30 +307,15 @@ public extension LLMSession { in url: URL = FileDownloader.defaultRootDestination, excludingModels: [any Model] = [] ) throws { - guard let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsPackageDescendants]) else { return } - - let excludingNormalized: Set = Set(excludingModels.compactMap { model -> URL? in - if let model = model as? LLMSession.DownloadModel { - return model.modelPath.resolvingSymlinksInPath().standardizedFileURL - } - if let model = model as? LLMSession.LocalModel { - return model.modelPath.resolvingSymlinksInPath().standardizedFileURL - } - return nil - }) - - for case let fileURL as URL in enumerator { - if excludingNormalized.contains(fileURL.resolvingSymlinksInPath().standardizedFileURL) { - enumerator.skipDescendants() - continue - } - - if (try fileURL.resourceValues(forKeys: [.isDirectoryKey])).isDirectory == false { - try FileManager.default.removeItem(at: fileURL) + let excludedURLs: [URL] = excludingModels.compactMap { model -> URL? in + switch model { + case let model as LLMSession.DownloadModel: model.modelPath + case let model as LLMSession.LocalModel: model.modelPath + default: nil } } - try FileManager.default.removeEmptyDirectories(in: url) + try FileManager.default.removeAllItems(in: url, excludingURLs: excludedURLs) } } diff --git a/Sources/LocalLLMClientUtility/FileManager+.swift b/Sources/LocalLLMClientUtility/FileManager+.swift index 56dfd6f..c4295d9 100644 --- a/Sources/LocalLLMClientUtility/FileManager+.swift +++ b/Sources/LocalLLMClientUtility/FileManager+.swift @@ -13,4 +13,24 @@ package extension FileManager { try removeItem(at: url) } + + func removeAllItems(in url: URL, excludingURLs: [URL] = [], removingEmptyDirectories: Bool = true) throws { + guard let enumerator = enumerator(at: url, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsPackageDescendants]) else { return } + + let excludingURLsNormalized: Set = Set(excludingURLs.map { $0.resolvingSymlinksInPath().standardizedFileURL + }) + + for case let fileURL as URL in enumerator { + if excludingURLsNormalized.contains(fileURL.resolvingSymlinksInPath().standardizedFileURL) { + enumerator.skipDescendants() + continue + } + + if (try fileURL.resourceValues(forKeys: [.isDirectoryKey])).isDirectory == false { + try removeItem(at: fileURL) + } + } + + if removingEmptyDirectories { try removeEmptyDirectories(in: url) } + } }