diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 6998ca3..e2a2bf1 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -15,7 +15,7 @@ import { saveSettings } from './utils/config'; import { - getBlockMetrics, + getCachedBlockMetrics, getSessionDuration, getTokenMetrics } from './utils/jsonl'; @@ -100,7 +100,7 @@ async function renderMultipleLines(data: StatusJSON) { let blockMetrics: BlockMetrics | null = null; if (hasBlockTimer) { - blockMetrics = getBlockMetrics(); + blockMetrics = getCachedBlockMetrics(); } // Create render context diff --git a/src/utils/__tests__/block-cache.test.ts b/src/utils/__tests__/block-cache.test.ts new file mode 100644 index 0000000..838ab12 --- /dev/null +++ b/src/utils/__tests__/block-cache.test.ts @@ -0,0 +1,291 @@ +import * as fs from 'fs'; +import { createHash } from 'node:crypto'; +import os from 'node:os'; +import path from 'node:path'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import { + getBlockCachePath, + readBlockCache, + writeBlockCache +} from '../jsonl'; + +function getExpectedCachePath(homeDir: string, configDir: string): string { + const normalizedConfigDir = path.resolve(configDir); + const configHash = createHash('sha256') + .update(normalizedConfigDir) + .digest('hex') + .slice(0, 16); + + return path.join(homeDir, '.cache', 'ccstatusline', `block-cache-${configHash}.json`); +} + +describe('Block Cache Functions', () => { + let tempDir: string; + let originalClaudeConfigDir: string | undefined; + + beforeEach(() => { + // Create a temp directory for test isolation + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-test-')); + // Mock os.homedir to use temp directory + vi.spyOn(os, 'homedir').mockReturnValue(tempDir); + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = path.join(tempDir, '.claude-default'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (originalClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } + // Clean up temp directory + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('getBlockCachePath', () => { + it('should return the correct cache path', () => { + const cachePath = getBlockCachePath(); + expect(cachePath).toBe(getExpectedCachePath(tempDir, path.join(tempDir, '.claude-default'))); + }); + + it('should return different cache paths for different config directories', () => { + const profileA = path.join(tempDir, '.claude-profile-a'); + const profileB = path.join(tempDir, '.claude-profile-b'); + + const pathA = getBlockCachePath(profileA); + const pathB = getBlockCachePath(profileB); + + expect(pathA).toBe(getExpectedCachePath(tempDir, profileA)); + expect(pathB).toBe(getExpectedCachePath(tempDir, profileB)); + expect(pathA).not.toBe(pathB); + }); + }); + + describe('readBlockCache', () => { + it('should return null when cache file does not exist', () => { + const result = readBlockCache(); + expect(result).toBeNull(); + }); + + it('should return cached date when cache file is valid', () => { + const testDate = new Date('2025-01-26T14:00:00.000Z'); + const cachePath = getBlockCachePath(); + const cacheDir = path.dirname(cachePath); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(cachePath, JSON.stringify({ startTime: testDate.toISOString() })); + + const result = readBlockCache(); + expect(result).toEqual(testDate); + }); + + it('should return cached date when configDir matches expected value', () => { + const testDate = new Date('2025-01-26T14:00:00.000Z'); + const configDir = path.join(tempDir, '.claude-profile-a'); + const cachePath = getBlockCachePath(configDir); + const cacheDir = path.dirname(cachePath); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync( + cachePath, + JSON.stringify({ startTime: testDate.toISOString(), configDir }) + ); + + const result = readBlockCache(configDir); + expect(result).toEqual(testDate); + }); + + it('should return null when cache configDir does not match expected value', () => { + const testDate = new Date('2025-01-26T14:00:00.000Z'); + const expectedConfigDir = path.join(tempDir, '.claude-profile-b'); + const cachePath = getBlockCachePath(expectedConfigDir); + const cacheDir = path.dirname(cachePath); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync( + cachePath, + JSON.stringify({ + startTime: testDate.toISOString(), + configDir: path.join(tempDir, '.claude-profile-a') + }) + ); + + const result = readBlockCache(expectedConfigDir); + expect(result).toBeNull(); + }); + + it('should return null when expected configDir is provided but cache has legacy schema', () => { + const testDate = new Date('2025-01-26T14:00:00.000Z'); + const expectedConfigDir = path.join(tempDir, '.claude-profile-a'); + const cachePath = getBlockCachePath(expectedConfigDir); + const cacheDir = path.dirname(cachePath); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(cachePath, JSON.stringify({ startTime: testDate.toISOString() })); + + const result = readBlockCache(expectedConfigDir); + expect(result).toBeNull(); + }); + + it('should return null when cache file has invalid JSON', () => { + const cachePath = getBlockCachePath(); + const cacheDir = path.dirname(cachePath); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(cachePath, 'not valid json'); + + const result = readBlockCache(); + expect(result).toBeNull(); + }); + + it('should return null when cache file has missing startTime', () => { + const cachePath = getBlockCachePath(); + const cacheDir = path.dirname(cachePath); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(cachePath, JSON.stringify({})); + + const result = readBlockCache(); + expect(result).toBeNull(); + }); + + it('should return null when startTime is not a string', () => { + const cachePath = getBlockCachePath(); + const cacheDir = path.dirname(cachePath); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(cachePath, JSON.stringify({ startTime: 12345 })); + + const result = readBlockCache(); + expect(result).toBeNull(); + }); + + it('should return null when startTime is invalid date string', () => { + const cachePath = getBlockCachePath(); + const cacheDir = path.dirname(cachePath); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(cachePath, JSON.stringify({ startTime: 'not a date' })); + + const result = readBlockCache(); + expect(result).toBeNull(); + }); + }); + + describe('writeBlockCache', () => { + it('should create directory and write cache file', () => { + const testDate = new Date('2025-01-26T14:00:00.000Z'); + const configDir = path.join(tempDir, '.claude-profile-a'); + + writeBlockCache(testDate, configDir); + + const cachePath = getBlockCachePath(configDir); + expect(fs.existsSync(cachePath)).toBe(true); + const content = fs.readFileSync(cachePath, 'utf-8'); + const parsed = JSON.parse(content) as { startTime: string; configDir: string }; + expect(parsed.startTime).toBe(testDate.toISOString()); + expect(parsed.configDir).toBe(path.resolve(configDir)); + }); + + it('should overwrite existing cache file', () => { + const firstDate = new Date('2025-01-26T14:00:00.000Z'); + const secondDate = new Date('2025-01-26T16:00:00.000Z'); + const configDir = path.join(tempDir, '.claude-profile-a'); + + writeBlockCache(firstDate, configDir); + writeBlockCache(secondDate, configDir); + + const cachePath = getBlockCachePath(configDir); + const content = fs.readFileSync(cachePath, 'utf-8'); + const parsed = JSON.parse(content) as { startTime: string; configDir: string }; + expect(parsed.startTime).toBe(secondDate.toISOString()); + expect(parsed.configDir).toBe(path.resolve(configDir)); + }); + }); +}); + +describe('getCachedBlockMetrics integration', () => { + let tempDir: string; + let originalClaudeConfigDir: string | undefined; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-test-')); + vi.spyOn(os, 'homedir').mockReturnValue(tempDir); + // Mock CLAUDE_CONFIG_DIR to point to a non-existent directory + // This ensures getBlockMetrics returns null when cache is expired + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = path.join(tempDir, '.claude-nonexistent'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + // Restore original CLAUDE_CONFIG_DIR + if (originalClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should return cached result when cache is within block duration', async () => { + const { getCachedBlockMetrics, writeBlockCache, getBlockCachePath } = await import('../jsonl'); + + // Set up a cache with a start time 2 hours ago (within 5-hour block) + const testStartTime = new Date(); + testStartTime.setHours(testStartTime.getHours() - 2); + + writeBlockCache(testStartTime); + + // Verify cache was written + expect(fs.existsSync(getBlockCachePath())).toBe(true); + + const result = getCachedBlockMetrics(); + + expect(result).not.toBeNull(); + expect(result?.startTime.getTime()).toBe(testStartTime.getTime()); + }); + + it('should recalculate when cache is expired (beyond block duration)', async () => { + const { getCachedBlockMetrics, writeBlockCache } = await import('../jsonl'); + + // Set up a cache with a start time 6 hours ago (beyond 5-hour block) + const expiredStartTime = new Date(); + expiredStartTime.setHours(expiredStartTime.getHours() - 6); + + writeBlockCache(expiredStartTime); + + const result = getCachedBlockMetrics(); + + // Should return null because cache is expired and no real JSONL files exist + expect(result).toBeNull(); + }); + + it('should recalculate when no cache exists', async () => { + const { getCachedBlockMetrics } = await import('../jsonl'); + + const result = getCachedBlockMetrics(); + + // Should return null because no cache and no real JSONL files exist + expect(result).toBeNull(); + }); + + it('should recalculate when cache belongs to a different config directory', async () => { + const { getCachedBlockMetrics, writeBlockCache } = await import('../jsonl'); + const profileA = path.join(tempDir, '.claude-profile-a'); + const profileB = path.join(tempDir, '.claude-profile-b'); + + const testStartTime = new Date(); + testStartTime.setHours(testStartTime.getHours() - 2); + + process.env.CLAUDE_CONFIG_DIR = profileA; + writeBlockCache(testStartTime); + + process.env.CLAUDE_CONFIG_DIR = profileB; + const result = getCachedBlockMetrics(); + + // Should return null because cache is fresh but scoped to a different profile + expect(result).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/utils/jsonl.ts b/src/utils/jsonl.ts index d00923c..6d35a1d 100644 --- a/src/utils/jsonl.ts +++ b/src/utils/jsonl.ts @@ -1,4 +1,6 @@ import * as fs from 'fs'; +import { createHash } from 'node:crypto'; +import os from 'node:os'; import path from 'node:path'; import { globSync } from 'tinyglobby'; import { promisify } from 'util'; @@ -15,6 +17,130 @@ import { getClaudeConfigDir } from './claude-settings'; const readFile = promisify(fs.readFile); const readFileSync = fs.readFileSync; const statSync = fs.statSync; +const writeFileSync = fs.writeFileSync; +const mkdirSync = fs.mkdirSync; +const existsSync = fs.existsSync; + +// --- Block Cache Functions --- + +interface BlockCache { + startTime: string; + configDir?: string; +} + +function normalizeConfigDir(configDir: string): string { + return path.resolve(configDir); +} + +/** + * Returns the path to the block cache file for a specific Claude config directory + */ +export function getBlockCachePath(configDir = getClaudeConfigDir()): string { + const normalizedConfigDir = normalizeConfigDir(configDir); + const configHash = createHash('sha256') + .update(normalizedConfigDir) + .digest('hex') + .slice(0, 16); + + return path.join( + os.homedir(), + '.cache', + 'ccstatusline', + `block-cache-${configHash}.json` + ); +} + +/** + * Reads the block cache file and returns the cached start time + * Returns null if cache doesn't exist or is invalid + */ +export function readBlockCache(expectedConfigDir?: string): Date | null { + try { + const normalizedExpectedConfigDir = expectedConfigDir !== undefined + ? normalizeConfigDir(expectedConfigDir) + : undefined; + const cachePath = getBlockCachePath(normalizedExpectedConfigDir); + if (!existsSync(cachePath)) { + return null; + } + const content = readFileSync(cachePath, 'utf-8'); + const cache = JSON.parse(content) as BlockCache; + if (typeof cache.startTime !== 'string') { + return null; + } + if (normalizedExpectedConfigDir !== undefined) { + if (typeof cache.configDir !== 'string') { + return null; + } + if (cache.configDir !== normalizedExpectedConfigDir) { + return null; + } + } + const date = new Date(cache.startTime); + if (Number.isNaN(date.getTime())) { + return null; + } + return date; + } catch { + return null; + } +} + +/** + * Writes the block start time to the cache file + * Creates the cache directory if it doesn't exist + */ +export function writeBlockCache(startTime: Date, configDir = getClaudeConfigDir()): void { + try { + const normalizedConfigDir = normalizeConfigDir(configDir); + const cachePath = getBlockCachePath(normalizedConfigDir); + const cacheDir = path.dirname(cachePath); + if (!existsSync(cacheDir)) { + mkdirSync(cacheDir, { recursive: true }); + } + const cache: BlockCache = { + startTime: startTime.toISOString(), + configDir: normalizedConfigDir + }; + writeFileSync(cachePath, JSON.stringify(cache), 'utf-8'); + } catch { + // Silently fail - caching is best-effort + } +} + +/** + * Gets block metrics with caching support + * Returns cached result if still valid, otherwise recalculates + */ +export function getCachedBlockMetrics(sessionDurationHours = 5): BlockMetrics | null { + const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000; + const now = new Date(); + const activeConfigDir = getClaudeConfigDir(); + + // Check cache first + const cachedStartTime = readBlockCache(activeConfigDir); + if (cachedStartTime) { + const blockEndTime = new Date(cachedStartTime.getTime() + sessionDurationMs); + if (now.getTime() <= blockEndTime.getTime()) { + // Cache is valid - return cached result + return { + startTime: cachedStartTime, + lastActivity: now // We don't cache lastActivity, use current time + }; + } + // Cache expired - need to recalculate + } + + // Cache miss or expired - run full calculation + const metrics = getBlockMetrics(); + + // Write to cache if we found a valid block + if (metrics) { + writeBlockCache(metrics.startTime, activeConfigDir); + } + + return metrics; +} export async function getSessionDuration(transcriptPath: string): Promise { try {