Files
kotej/Sources/KotejEngine/Tree.swift
T
alexandrev-tibco f85c746af0 Rename to Kotej and add the app icon
"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
2026-07-27 17:24:05 +02:00

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
}
}