// // SessionModel.swift // Metrika // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Copyright © 2026 Simon-Pierre Boucher. All rights reserved. // import Observation import SwiftUI import ZQData import ZQEngine import ZQGraphics import ZQPlugins /// Observable session state for the UI. Every command — typed in the /// console or replayed from a do-file — goes through the one `ZQSession` /// execution path (CLAUDE.md §7). @Observable @MainActor final class SessionModel { struct ConsoleEntry: Identifiable, Equatable { let id = UUID() let command: String let output: String let isError: Bool } struct VariableInfo: Identifiable, Equatable { var id: String { name } let name: String let type: String let missingCount: Int } private(set) var entries: [ConsoleEntry] = [] private(set) var variables: [VariableInfo] = [] private(set) var observationCount = 0 private(set) var lastPlot: ZQPlotSpec? private(set) var isRunning = false private(set) var history: [String] = [] /// Snapshot of the working dataset for the data browser. private(set) var frame = try! ZQDataFrame() // empty init cannot fail private var session: ZQSession? init() { do { // Built-in native plugins ship with the app; user .zyq script // commands are discovered from Application Support. session = try ZQSession(plugins: [ZScorePlugin()]) } catch { entries.append(ConsoleEntry( command: "", output: "failed to start engine: \(error)", isError: true )) } #if DEBUG // XCUITest hook: the sandboxed app cannot read files staged by the // test runner, so the test passes CSV content via the environment // and the app materializes it inside its own container. if let csv = ProcessInfo.processInfo.environment["METRIKA_UITEST_CSV"] { let url = FileManager.default.temporaryDirectory .appendingPathComponent("uitest_smoke.csv") try? Data(csv.utf8).write(to: url) Task { await run("use \(url.path)") } } // Headless driving: semicolon-separated commands run at startup. if let script = ProcessInfo.processInfo.environment["METRIKA_AUTORUN"] { Task { for command in script.split(separator: ";") { await run(command.trimmingCharacters(in: .whitespaces)) } } } #endif } func run(_ command: String) async { let line = command.trimmingCharacters(in: .whitespaces) guard !line.isEmpty, let session else { return } history.append(line) isRunning = true defer { isRunning = false } do { let result = try await session.execute(line) entries.append(ConsoleEntry(command: line, output: result.text, isError: false)) } catch { entries.append(ConsoleEntry(command: line, output: "\(error)", isError: true)) } await refreshDatasetState() } private func refreshDatasetState() async { guard let session else { return } let frame = await session.frame self.frame = frame observationCount = frame.rowCount variables = frame.columns.map { column in VariableInfo( name: column.name, type: column.data.isNumeric ? "float64" : "string", missingCount: column.data.missingCount ) } lastPlot = await session.lastPlot } struct FilterError: Error, CustomStringConvertible { let description: String } /// Filter-bar support: compiles text to an `if` mask over the current /// dataset. Fails with the parser's error message. func filterMask(_ expression: String) async -> Result<[Bool], FilterError> { guard let session else { return .failure(FilterError(description: "engine not running")) } do { return .success(try await session.conditionMask(expression)) } catch { return .failure(FilterError(description: "\(error)")) } } /// Do-file support: runs a whole script through the shared execution /// path, appending its output to the console. func runScript(_ text: String) async { guard let session else { return } isRunning = true defer { isRunning = false } do { let result = try await session.executeScript(text) entries.append(ConsoleEntry( command: "do-file (\(text.split(separator: "\n").count) lines)", output: result.text, isError: false )) } catch { entries.append(ConsoleEntry( command: "do-file", output: "\(error)", isError: true )) } await refreshDatasetState() } }