From 0e100610e5efad3317c7d3d9bb1594f134eb9ce2 Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Tue, 28 Apr 2026 22:26:15 +0200 Subject: [PATCH] feat(state): add CJS reader for phobiCS-tui state file readSelection(path) -> discriminated union of: - ok (the parsed v1 payload) - missing (file does not exist) - stale (updated_at older than FRESHNESS_MS = 60s) - invalid (bad JSON or unknown version) Lives outside src/tui/ because the MCP server (CJS) consumes it; the TUI itself only writes the file. --- src/state-read.ts | 46 +++++++++++++++++++++++++++ tests/unit/state-read.test.ts | 59 +++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 src/state-read.ts create mode 100644 tests/unit/state-read.test.ts diff --git a/src/state-read.ts b/src/state-read.ts new file mode 100644 index 0000000..534b315 --- /dev/null +++ b/src/state-read.ts @@ -0,0 +1,46 @@ +import * as fs from 'fs'; + +export const FRESHNESS_MS = 60_000; + +export interface SelectionPayload { + version: 1; + updated_at: string; + project_dir: string; + device: string; + selection: { + kind: string; + name: string; + path: string; + abs_path: string; + }; + viewer_line: number; +} + +export type ReadResult = + | { status: 'ok'; payload: SelectionPayload } + | { status: 'missing' } + | { status: 'stale' } + | { status: 'invalid'; reason: string }; + +export async function readSelection(filePath: string): Promise { + let text: string; + try { + text = await fs.promises.readFile(filePath, 'utf8'); + } catch { + return { status: 'missing' }; + } + let parsed: SelectionPayload; + try { + parsed = JSON.parse(text); + } catch (err) { + return { status: 'invalid', reason: (err as Error).message }; + } + if (parsed.version !== 1) { + return { status: 'invalid', reason: `unsupported version ${parsed.version}` }; + } + const ageMs = Date.now() - new Date(parsed.updated_at).getTime(); + if (Number.isNaN(ageMs) || ageMs > FRESHNESS_MS) { + return { status: 'stale' }; + } + return { status: 'ok', payload: parsed }; +} diff --git a/tests/unit/state-read.test.ts b/tests/unit/state-read.test.ts new file mode 100644 index 0000000..523454a --- /dev/null +++ b/tests/unit/state-read.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { readSelection, FRESHNESS_MS } from '../../src/state-read'; + +async function tmpFile(content: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'phobics-read-')); + const f = path.join(dir, 'tui-state.json'); + await fs.writeFile(f, content, 'utf8'); + return f; +} + +const FRESH = JSON.stringify({ + version: 1, + updated_at: new Date().toISOString(), + project_dir: '/p', + device: 'D1', + selection: { + kind: 'FB', + name: 'FB_Test', + path: 'a/FB_Test.st', + abs_path: '/abs/a/FB_Test.st', + }, + viewer_line: 12, +}); + +describe('readSelection', () => { + it('returns the parsed payload when fresh', async () => { + const f = await tmpFile(FRESH); + const r = await readSelection(f); + expect(r.status).toBe('ok'); + if (r.status === 'ok') { + expect(r.payload.device).toBe('D1'); + expect(r.payload.selection.name).toBe('FB_Test'); + } + }); + + it('returns stale when updated_at is older than the freshness window', async () => { + const old = JSON.stringify({ + ...JSON.parse(FRESH), + updated_at: new Date(Date.now() - FRESHNESS_MS - 5000).toISOString(), + }); + const f = await tmpFile(old); + const r = await readSelection(f); + expect(r.status).toBe('stale'); + }); + + it('returns missing when the file does not exist', async () => { + const r = await readSelection('/nonexistent/tui-state.json'); + expect(r.status).toBe('missing'); + }); + + it('returns invalid when the JSON is malformed', async () => { + const f = await tmpFile('not json'); + const r = await readSelection(f); + expect(r.status).toBe('invalid'); + }); +});