f85c746af0
"Cotejo" means nothing outside Spanish and is hard to pronounce elsewhere, so the project is now Kotej: same root, but it reads and sounds the same in English and Spanish. Renamed throughout — engine, targets, bundle ids and the URL scheme, which is now kotej://compare. Icon generated with Codex: two facing panels split by a seam, content lines on each side and one amber line marking a difference — the tool's job at a glance, still legible at 16pt. 34 tests still green; kotej:// verified end to end against the demo JARs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
182 lines
8.4 KiB
Swift
182 lines
8.4 KiB
Swift
import Foundation
|
|
|
|
/// Where a node's bytes live. Archives are browsed in place, so an entry inside a
|
|
/// JAR is addressed without ever unpacking the whole thing to disk.
|
|
public enum NodeSource: Sendable {
|
|
case fileSystem(URL)
|
|
case archiveEntry(archive: URL, path: String)
|
|
/// A nested archive (a JAR inside an EAR): the bytes were extracted once so
|
|
/// its own central directory could be read.
|
|
case extracted(URL, origin: String)
|
|
}
|
|
|
|
/// One file or folder in a comparison tree. Archives appear as folders.
|
|
public final class Node: @unchecked Sendable {
|
|
public let name: String
|
|
/// Path relative to the root of its side, used to pair the two trees.
|
|
public let path: String
|
|
public let isDirectory: Bool
|
|
public let size: UInt64
|
|
/// CRC32 from the archive's central directory, when the node came from one.
|
|
/// Present means "we already know the content fingerprint for free".
|
|
public let crc32: UInt32?
|
|
public let source: NodeSource
|
|
/// True when this folder is really an archive shown as a folder.
|
|
public let isArchive: Bool
|
|
public private(set) var children: [Node]
|
|
|
|
init(name: String, path: String, isDirectory: Bool, size: UInt64 = 0,
|
|
crc32: UInt32? = nil, source: NodeSource, isArchive: Bool = false,
|
|
children: [Node] = []) {
|
|
self.name = name
|
|
self.path = path
|
|
self.isDirectory = isDirectory
|
|
self.size = size
|
|
self.crc32 = crc32
|
|
self.source = source
|
|
self.isArchive = isArchive
|
|
self.children = children
|
|
}
|
|
|
|
func add(_ child: Node) { children.append(child) }
|
|
func sortChildren() {
|
|
children.sort { left, right in
|
|
if left.isDirectory != right.isDirectory { return left.isDirectory }
|
|
return left.name.localizedStandardCompare(right.name) == .orderedAscending
|
|
}
|
|
children.forEach { $0.sortChildren() }
|
|
}
|
|
|
|
/// Reads this node's bytes, inflating from its archive when needed.
|
|
public func data() throws -> Data {
|
|
switch source {
|
|
case .fileSystem(let url), .extracted(let url, _):
|
|
return try Data(contentsOf: url, options: .mappedIfSafe)
|
|
case .archiveEntry(let archive, let entryPath):
|
|
let reader = try ZipReader(url: archive)
|
|
guard let entry = reader.entries.first(where: { $0.path == entryPath }) else {
|
|
throw ZipReader.ZipError.corrupt("entry \(entryPath) vanished")
|
|
}
|
|
return try reader.data(for: entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Builds comparison trees from folders and archives.
|
|
public enum TreeScanner {
|
|
/// Scans a folder or an archive into a tree. Archives — including archives
|
|
/// nested inside archives (an EAR's JARs) — are expanded as folders, which is
|
|
/// what makes comparing deployment artefacts useful.
|
|
public static func scan(_ url: URL, expandNestedArchives: Bool = true) throws -> Node {
|
|
var isDirectory: ObjCBool = false
|
|
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else {
|
|
throw CocoaError(.fileNoSuchFile)
|
|
}
|
|
if isDirectory.boolValue {
|
|
return try scanDirectory(url, expandNestedArchives: expandNestedArchives)
|
|
}
|
|
if ZipReader.isArchive(url) {
|
|
return try scanArchive(url, expandNestedArchives: expandNestedArchives)
|
|
}
|
|
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
|
|
return Node(name: url.lastPathComponent, path: url.lastPathComponent, isDirectory: false,
|
|
size: UInt64(size), source: .fileSystem(url))
|
|
}
|
|
|
|
// MARK: Folders
|
|
|
|
private static func scanDirectory(_ url: URL, expandNestedArchives: Bool) throws -> Node {
|
|
let root = Node(name: url.lastPathComponent, path: "", isDirectory: true, source: .fileSystem(url))
|
|
try fill(root, at: url, prefix: "", expandNestedArchives: expandNestedArchives)
|
|
root.sortChildren()
|
|
return root
|
|
}
|
|
|
|
private static func fill(_ parent: Node, at url: URL, prefix: String, expandNestedArchives: Bool) throws {
|
|
let keys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey]
|
|
let entries = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: keys,
|
|
options: [.skipsHiddenFiles])
|
|
for entry in entries {
|
|
let values = try? entry.resourceValues(forKeys: Set(keys))
|
|
let childPath = prefix.isEmpty ? entry.lastPathComponent : prefix + "/" + entry.lastPathComponent
|
|
|
|
if values?.isDirectory == true {
|
|
let node = Node(name: entry.lastPathComponent, path: childPath, isDirectory: true,
|
|
source: .fileSystem(entry))
|
|
parent.add(node)
|
|
try fill(node, at: entry, prefix: childPath, expandNestedArchives: expandNestedArchives)
|
|
} else if expandNestedArchives, ZipReader.isArchive(entry) {
|
|
// Show the archive as a folder, keeping its own name.
|
|
if let archiveNode = try? scanArchive(entry, expandNestedArchives: expandNestedArchives,
|
|
path: childPath) {
|
|
parent.add(archiveNode)
|
|
} else {
|
|
parent.add(fileNode(entry, path: childPath, size: values?.fileSize ?? 0))
|
|
}
|
|
} else {
|
|
parent.add(fileNode(entry, path: childPath, size: values?.fileSize ?? 0))
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func fileNode(_ url: URL, path: String, size: Int) -> Node {
|
|
Node(name: url.lastPathComponent, path: path, isDirectory: false,
|
|
size: UInt64(size), source: .fileSystem(url))
|
|
}
|
|
|
|
// MARK: Archives
|
|
|
|
/// Turns an archive's flat entry list into a tree of folders and files.
|
|
static func scanArchive(_ url: URL, expandNestedArchives: Bool, path: String? = nil) throws -> Node {
|
|
let reader = try ZipReader(url: url)
|
|
let rootPath = path ?? url.lastPathComponent
|
|
let root = Node(name: url.lastPathComponent, path: rootPath, isDirectory: true,
|
|
source: .fileSystem(url), isArchive: true)
|
|
|
|
var folders: [String: Node] = ["": root]
|
|
|
|
func folder(_ folderPath: String) -> Node {
|
|
if let existing = folders[folderPath] { return existing }
|
|
let parentPath = (folderPath as NSString).deletingLastPathComponent
|
|
let parent = folder(parentPath)
|
|
let node = Node(name: (folderPath as NSString).lastPathComponent,
|
|
path: rootPath + "/" + folderPath, isDirectory: true,
|
|
source: .archiveEntry(archive: url, path: folderPath))
|
|
parent.add(node)
|
|
folders[folderPath] = node
|
|
return node
|
|
}
|
|
|
|
for entry in reader.entries where !entry.isDirectory {
|
|
let parentPath = (entry.path as NSString).deletingLastPathComponent
|
|
let parent = folder(parentPath)
|
|
let childPath = rootPath + "/" + entry.path
|
|
let name = (entry.path as NSString).lastPathComponent
|
|
|
|
// A JAR inside an EAR: extract once so its own entries can be listed.
|
|
if expandNestedArchives, ZipReader.archiveExtensions.contains((name as NSString).pathExtension.lowercased()) {
|
|
if let nested = try? extractNested(reader: reader, entry: entry, name: name),
|
|
let nestedNode = try? scanArchive(nested, expandNestedArchives: expandNestedArchives,
|
|
path: childPath) {
|
|
parent.add(nestedNode)
|
|
continue
|
|
}
|
|
}
|
|
parent.add(Node(name: name, path: childPath, isDirectory: false,
|
|
size: entry.uncompressedSize, crc32: entry.crc32,
|
|
source: .archiveEntry(archive: url, path: entry.path)))
|
|
}
|
|
root.sortChildren()
|
|
return root
|
|
}
|
|
|
|
private static func extractNested(reader: ZipReader, entry: ZipReader.Entry, name: String) throws -> URL {
|
|
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
|
|
.appendingPathComponent("kotej-nested-\(UUID().uuidString)")
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
let url = directory.appendingPathComponent(name)
|
|
try reader.data(for: entry).write(to: url)
|
|
return url
|
|
}
|
|
}
|