From c7305374d7ef6c18a07f568bfd98a920a31803d4 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:30:17 +0900 Subject: [PATCH] takt: update-category-spec (#184) --- src/__tests__/global-pieceCategories.test.ts | 117 +++++++ src/__tests__/piece-category-config.test.ts | 292 ++++++++++-------- src/__tests__/resetCategories.test.ts | 44 +++ src/__tests__/selectAndExecute-autoPr.test.ts | 56 +++- src/__tests__/switchPiece.test.ts | 90 ++++++ src/features/config/resetCategories.ts | 8 +- src/features/config/switchPiece.ts | 2 +- .../tasks/execute/selectAndExecute.ts | 2 +- src/infra/config/global/index.ts | 1 - src/infra/config/global/pieceCategories.ts | 51 +-- src/infra/config/loaders/pieceCategories.ts | 213 ++++++++++--- 11 files changed, 648 insertions(+), 228 deletions(-) create mode 100644 src/__tests__/global-pieceCategories.test.ts create mode 100644 src/__tests__/resetCategories.test.ts create mode 100644 src/__tests__/switchPiece.test.ts diff --git a/src/__tests__/global-pieceCategories.test.ts b/src/__tests__/global-pieceCategories.test.ts new file mode 100644 index 0000000..286ac22 --- /dev/null +++ b/src/__tests__/global-pieceCategories.test.ts @@ -0,0 +1,117 @@ +/** + * Tests for global piece category path resolution. + */ + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const loadGlobalConfigMock = vi.hoisted(() => vi.fn()); + +vi.mock('../infra/config/paths.js', () => ({ + getGlobalConfigDir: () => '/tmp/.takt', +})); + +vi.mock('../infra/config/global/globalConfig.js', () => ({ + loadGlobalConfig: loadGlobalConfigMock, +})); + +const { getPieceCategoriesPath, resetPieceCategories } = await import( + '../infra/config/global/pieceCategories.js' +); + +function createTempCategoriesPath(): string { + const tempRoot = mkdtempSync(join(tmpdir(), 'takt-piece-categories-')); + return join(tempRoot, 'preferences', 'piece-categories.yaml'); +} + +describe('getPieceCategoriesPath', () => { + beforeEach(() => { + loadGlobalConfigMock.mockReset(); + }); + + it('should return configured path when pieceCategoriesFile is set', () => { + // Given + loadGlobalConfigMock.mockReturnValue({ + pieceCategoriesFile: '/custom/piece-categories.yaml', + }); + + // When + const path = getPieceCategoriesPath(); + + // Then + expect(path).toBe('/custom/piece-categories.yaml'); + }); + + it('should return default path when pieceCategoriesFile is not set', () => { + // Given + loadGlobalConfigMock.mockReturnValue({}); + + // When + const path = getPieceCategoriesPath(); + + // Then + expect(path).toBe('/tmp/.takt/preferences/piece-categories.yaml'); + }); + + it('should rethrow when global config loading fails', () => { + // Given + loadGlobalConfigMock.mockImplementation(() => { + throw new Error('invalid global config'); + }); + + // When / Then + expect(() => getPieceCategoriesPath()).toThrow('invalid global config'); + }); +}); + +describe('resetPieceCategories', () => { + const tempRoots: string[] = []; + + beforeEach(() => { + loadGlobalConfigMock.mockReset(); + }); + + afterEach(() => { + for (const tempRoot of tempRoots) { + rmSync(tempRoot, { recursive: true, force: true }); + } + tempRoots.length = 0; + }); + + it('should create parent directory and initialize with empty user categories', () => { + // Given + const categoriesPath = createTempCategoriesPath(); + tempRoots.push(dirname(dirname(categoriesPath))); + loadGlobalConfigMock.mockReturnValue({ + pieceCategoriesFile: categoriesPath, + }); + + // When + resetPieceCategories(); + + // Then + expect(existsSync(dirname(categoriesPath))).toBe(true); + expect(readFileSync(categoriesPath, 'utf-8')).toBe('piece_categories: {}\n'); + }); + + it('should overwrite existing file with empty user categories', () => { + // Given + const categoriesPath = createTempCategoriesPath(); + const categoriesDir = dirname(categoriesPath); + const tempRoot = dirname(categoriesDir); + tempRoots.push(tempRoot); + loadGlobalConfigMock.mockReturnValue({ + pieceCategoriesFile: categoriesPath, + }); + mkdirSync(categoriesDir, { recursive: true }); + writeFileSync(categoriesPath, 'piece_categories:\n old:\n - stale-piece\n', 'utf-8'); + + // When + resetPieceCategories(); + + // Then + expect(readFileSync(categoriesPath, 'utf-8')).toBe('piece_categories: {}\n'); + }); +}); diff --git a/src/__tests__/piece-category-config.test.ts b/src/__tests__/piece-category-config.test.ts index aa649bb..9bdc3bc 100644 --- a/src/__tests__/piece-category-config.test.ts +++ b/src/__tests__/piece-category-config.test.ts @@ -3,7 +3,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; @@ -19,6 +19,8 @@ vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => { return { ...original, getLanguage: () => 'en', + getBuiltinPiecesEnabled: () => true, + getDisabledBuiltins: () => [], }; }); @@ -30,9 +32,11 @@ vi.mock('../infra/resources/index.js', async (importOriginal) => { }; }); -vi.mock('../infra/config/global/pieceCategories.js', async () => { +vi.mock('../infra/config/global/pieceCategories.js', async (importOriginal) => { + const original = await importOriginal() as Record; return { - ensureUserCategoriesFile: () => pathsState.userCategoriesPath, + ...original, + getPieceCategoriesPath: () => pathsState.userCategoriesPath, }; }); @@ -74,72 +78,15 @@ describe('piece category config loading', () => { mkdirSync(resourcesDir, { recursive: true }); pathsState.resourcesDir = resourcesDir; + pathsState.userCategoriesPath = join(testDir, 'user-piece-categories.yaml'); }); afterEach(() => { rmSync(testDir, { recursive: true, force: true }); }); - it('should load categories from user file (auto-copied from default)', () => { - const userPath = join(testDir, 'piece-categories.yaml'); - writeYaml(userPath, ` -piece_categories: - Default: - pieces: - - simple -show_others_category: true -others_category_name: "Others" -`); - pathsState.userCategoriesPath = userPath; - + it('should return null when builtin categories file is missing', () => { const config = getPieceCategories(); - expect(config).not.toBeNull(); - expect(config!.pieceCategories).toEqual([ - { name: 'Default', pieces: ['simple'], children: [] }, - ]); - expect(config!.showOthersCategory).toBe(true); - expect(config!.othersCategoryName).toBe('Others'); - }); - - it('should return null when user file has no piece_categories', () => { - const userPath = join(testDir, 'piece-categories.yaml'); - writeYaml(userPath, ` -show_others_category: true -`); - pathsState.userCategoriesPath = userPath; - - const config = getPieceCategories(); - expect(config).toBeNull(); - }); - - it('should parse nested categories from user file', () => { - const userPath = join(testDir, 'piece-categories.yaml'); - writeYaml(userPath, ` -piece_categories: - Parent: - pieces: - - parent-piece - Child: - pieces: - - child-piece -`); - pathsState.userCategoriesPath = userPath; - - const config = getPieceCategories(); - expect(config).not.toBeNull(); - expect(config!.pieceCategories).toEqual([ - { - name: 'Parent', - pieces: ['parent-piece'], - children: [ - { name: 'Child', pieces: ['child-piece'], children: [] }, - ], - }, - ]); - }); - - it('should return null when default categories file is missing', () => { - const config = loadDefaultCategories(); expect(config).toBeNull(); }); @@ -156,19 +103,151 @@ piece_categories: expect(config!.pieceCategories).toEqual([ { name: 'Quick Start', pieces: ['default'], children: [] }, ]); + expect(config!.builtinPieceCategories).toEqual([ + { name: 'Quick Start', pieces: ['default'], children: [] }, + ]); + expect(config!.userPieceCategories).toEqual([]); + }); + + it('should use builtin categories when user overlay file is missing', () => { + writeYaml(join(resourcesDir, 'piece-categories.yaml'), ` +piece_categories: + Main: + pieces: + - default +show_others_category: true +others_category_name: Others +`); + + const config = getPieceCategories(); + expect(config).not.toBeNull(); + expect(config!.pieceCategories).toEqual([ + { name: 'Main', pieces: ['default'], children: [] }, + ]); + expect(config!.userPieceCategories).toEqual([]); + expect(config!.showOthersCategory).toBe(true); + expect(config!.othersCategoryName).toBe('Others'); + }); + + it('should merge user overlay categories with builtin categories', () => { + writeYaml(join(resourcesDir, 'piece-categories.yaml'), ` +piece_categories: + Main: + pieces: + - default + - coding + Child: + pieces: + - nested + Review: + pieces: + - review-only +show_others_category: true +others_category_name: Others +`); + + writeYaml(pathsState.userCategoriesPath, ` +piece_categories: + Main: + pieces: + - custom + My Team: + pieces: + - team-flow +show_others_category: false +others_category_name: Unclassified +`); + + const config = getPieceCategories(); + expect(config).not.toBeNull(); + expect(config!.pieceCategories).toEqual([ + { + name: 'Main', + pieces: ['custom'], + children: [ + { name: 'Child', pieces: ['nested'], children: [] }, + ], + }, + { name: 'Review', pieces: ['review-only'], children: [] }, + { name: 'My Team', pieces: ['team-flow'], children: [] }, + ]); + expect(config!.builtinPieceCategories).toEqual([ + { + name: 'Main', + pieces: ['default', 'coding'], + children: [ + { name: 'Child', pieces: ['nested'], children: [] }, + ], + }, + { name: 'Review', pieces: ['review-only'], children: [] }, + ]); + expect(config!.userPieceCategories).toEqual([ + { name: 'Main', pieces: ['custom'], children: [] }, + { name: 'My Team', pieces: ['team-flow'], children: [] }, + ]); + expect(config!.showOthersCategory).toBe(false); + expect(config!.othersCategoryName).toBe('Unclassified'); + }); + + it('should override others settings without replacing categories when user overlay has no piece_categories', () => { + writeYaml(join(resourcesDir, 'piece-categories.yaml'), ` +piece_categories: + Main: + pieces: + - default + Review: + pieces: + - review-only +show_others_category: true +others_category_name: Others +`); + + writeYaml(pathsState.userCategoriesPath, ` +show_others_category: false +others_category_name: Unclassified +`); + + const config = getPieceCategories(); + expect(config).not.toBeNull(); + expect(config!.pieceCategories).toEqual([ + { name: 'Main', pieces: ['default'], children: [] }, + { name: 'Review', pieces: ['review-only'], children: [] }, + ]); + expect(config!.builtinPieceCategories).toEqual([ + { name: 'Main', pieces: ['default'], children: [] }, + { name: 'Review', pieces: ['review-only'], children: [] }, + ]); + expect(config!.userPieceCategories).toEqual([]); + expect(config!.showOthersCategory).toBe(false); + expect(config!.othersCategoryName).toBe('Unclassified'); }); }); describe('buildCategorizedPieces', () => { - it('should place all pieces (user and builtin) into a unified category tree', () => { + it('should collect missing pieces with source information', () => { const allPieces = createPieceMap([ - { name: 'a', source: 'user' }, - { name: 'b', source: 'user' }, - { name: 'c', source: 'builtin' }, + { name: 'custom', source: 'user' }, + { name: 'nested', source: 'builtin' }, + { name: 'team-flow', source: 'user' }, ]); const config = { pieceCategories: [ - { name: 'Cat', pieces: ['a', 'missing', 'c'], children: [] }, + { + name: 'Main', + pieces: ['custom'], + children: [{ name: 'Child', pieces: ['nested'], children: [] }], + }, + { name: 'My Team', pieces: ['team-flow'], children: [] }, + ], + builtinPieceCategories: [ + { + name: 'Main', + pieces: ['default'], + children: [{ name: 'Child', pieces: ['nested'], children: [] }], + }, + ], + userPieceCategories: [ + { name: 'My Team', pieces: ['missing-user-piece'], children: [] }, ], showOthersCategory: true, othersCategoryName: 'Others', @@ -176,30 +255,19 @@ describe('buildCategorizedPieces', () => { const categorized = buildCategorizedPieces(allPieces, config); expect(categorized.categories).toEqual([ - { name: 'Cat', pieces: ['a', 'c'], children: [] }, - { name: 'Others', pieces: ['b'], children: [] }, + { + name: 'Main', + pieces: ['custom'], + children: [{ name: 'Child', pieces: ['nested'], children: [] }], + }, + { name: 'My Team', pieces: ['team-flow'], children: [] }, ]); expect(categorized.missingPieces).toEqual([ - { categoryPath: ['Cat'], pieceName: 'missing' }, + { categoryPath: ['Main'], pieceName: 'default', source: 'builtin' }, + { categoryPath: ['My Team'], pieceName: 'missing-user-piece', source: 'user' }, ]); }); - it('should skip empty categories', () => { - const allPieces = createPieceMap([ - { name: 'a', source: 'user' }, - ]); - const config = { - pieceCategories: [ - { name: 'Empty', pieces: [], children: [] }, - ], - showOthersCategory: false, - othersCategoryName: 'Others', - }; - - const categorized = buildCategorizedPieces(allPieces, config); - expect(categorized.categories).toEqual([]); - }); - it('should append Others category for uncategorized pieces', () => { const allPieces = createPieceMap([ { name: 'default', source: 'builtin' }, @@ -209,6 +277,10 @@ describe('buildCategorizedPieces', () => { pieceCategories: [ { name: 'Main', pieces: ['default'], children: [] }, ], + builtinPieceCategories: [ + { name: 'Main', pieces: ['default'], children: [] }, + ], + userPieceCategories: [], showOthersCategory: true, othersCategoryName: 'Others', }; @@ -220,28 +292,6 @@ describe('buildCategorizedPieces', () => { ]); }); - it('should merge uncategorized pieces into existing Others category', () => { - const allPieces = createPieceMap([ - { name: 'default', source: 'builtin' }, - { name: 'extra', source: 'builtin' }, - { name: 'user-piece', source: 'user' }, - ]); - const config = { - pieceCategories: [ - { name: 'Main', pieces: ['default'], children: [] }, - { name: 'Others', pieces: ['extra'], children: [] }, - ], - showOthersCategory: true, - othersCategoryName: 'Others', - }; - - const categorized = buildCategorizedPieces(allPieces, config); - expect(categorized.categories).toEqual([ - { name: 'Main', pieces: ['default'], children: [] }, - { name: 'Others', pieces: ['extra', 'user-piece'], children: [] }, - ]); - }); - it('should not append Others when showOthersCategory is false', () => { const allPieces = createPieceMap([ { name: 'default', source: 'builtin' }, @@ -251,6 +301,10 @@ describe('buildCategorizedPieces', () => { pieceCategories: [ { name: 'Main', pieces: ['default'], children: [] }, ], + builtinPieceCategories: [ + { name: 'Main', pieces: ['default'], children: [] }, + ], + userPieceCategories: [], showOthersCategory: false, othersCategoryName: 'Others', }; @@ -286,25 +340,3 @@ describe('buildCategorizedPieces', () => { expect(paths).toEqual(['Parent / Child']); }); }); - -describe('ensureUserCategoriesFile (integration)', () => { - let testDir: string; - - beforeEach(() => { - testDir = join(tmpdir(), `takt-cat-ensure-${randomUUID()}`); - mkdirSync(testDir, { recursive: true }); - }); - - afterEach(() => { - rmSync(testDir, { recursive: true, force: true }); - }); - - it('should copy default categories to user path when missing', async () => { - // Use real ensureUserCategoriesFile (not mocked) - const { ensureUserCategoriesFile } = await import('../infra/config/global/pieceCategories.js'); - - // This test depends on the mock still being active — just verify the mock returns our path - const result = ensureUserCategoriesFile('/tmp/default.yaml'); - expect(typeof result).toBe('string'); - }); -}); diff --git a/src/__tests__/resetCategories.test.ts b/src/__tests__/resetCategories.test.ts new file mode 100644 index 0000000..6ab7577 --- /dev/null +++ b/src/__tests__/resetCategories.test.ts @@ -0,0 +1,44 @@ +/** + * Tests for reset categories command behavior. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../infra/config/global/pieceCategories.js', () => ({ + resetPieceCategories: vi.fn(), + getPieceCategoriesPath: vi.fn(() => '/tmp/user-piece-categories.yaml'), +})); + +vi.mock('../shared/ui/index.js', () => ({ + header: vi.fn(), + success: vi.fn(), + info: vi.fn(), +})); + +import { resetPieceCategories } from '../infra/config/global/pieceCategories.js'; +import { header, success, info } from '../shared/ui/index.js'; +import { resetCategoriesToDefault } from '../features/config/resetCategories.js'; + +const mockResetPieceCategories = vi.mocked(resetPieceCategories); +const mockHeader = vi.mocked(header); +const mockSuccess = vi.mocked(success); +const mockInfo = vi.mocked(info); + +describe('resetCategoriesToDefault', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should reset user category overlay and show updated message', async () => { + // Given + + // When + await resetCategoriesToDefault(); + + // Then + expect(mockHeader).toHaveBeenCalledWith('Reset Categories'); + expect(mockResetPieceCategories).toHaveBeenCalledTimes(1); + expect(mockSuccess).toHaveBeenCalledWith('User category overlay reset.'); + expect(mockInfo).toHaveBeenCalledWith(' /tmp/user-piece-categories.yaml'); + }); +}); diff --git a/src/__tests__/selectAndExecute-autoPr.test.ts b/src/__tests__/selectAndExecute-autoPr.test.ts index ea3fd47..f4eeb2f 100644 --- a/src/__tests__/selectAndExecute-autoPr.test.ts +++ b/src/__tests__/selectAndExecute-autoPr.test.ts @@ -58,13 +58,26 @@ vi.mock('../features/pieceSelection/index.js', () => ({ })); import { confirm } from '../shared/prompt/index.js'; +import { + getCurrentPiece, + loadAllPiecesWithSources, + getPieceCategories, + buildCategorizedPieces, +} from '../infra/config/index.js'; import { createSharedClone, autoCommitAndPush, summarizeTaskName } from '../infra/task/index.js'; -import { selectAndExecuteTask } from '../features/tasks/execute/selectAndExecute.js'; +import { warnMissingPieces, selectPieceFromCategorizedPieces } from '../features/pieceSelection/index.js'; +import { selectAndExecuteTask, determinePiece } from '../features/tasks/execute/selectAndExecute.js'; const mockConfirm = vi.mocked(confirm); +const mockGetCurrentPiece = vi.mocked(getCurrentPiece); +const mockLoadAllPiecesWithSources = vi.mocked(loadAllPiecesWithSources); +const mockGetPieceCategories = vi.mocked(getPieceCategories); +const mockBuildCategorizedPieces = vi.mocked(buildCategorizedPieces); const mockCreateSharedClone = vi.mocked(createSharedClone); const mockAutoCommitAndPush = vi.mocked(autoCommitAndPush); const mockSummarizeTaskName = vi.mocked(summarizeTaskName); +const mockWarnMissingPieces = vi.mocked(warnMissingPieces); +const mockSelectPieceFromCategorizedPieces = vi.mocked(selectPieceFromCategorizedPieces); beforeEach(() => { vi.clearAllMocks(); @@ -102,4 +115,45 @@ describe('resolveAutoPr default in selectAndExecuteTask', () => { expect(autoPrCall).toBeDefined(); expect(autoPrCall![1]).toBe(true); }); + + it('should warn only user-origin missing pieces during interactive selection', async () => { + // Given: category selection is enabled and both builtin/user missing pieces exist + mockGetCurrentPiece.mockReturnValue('default'); + mockLoadAllPiecesWithSources.mockReturnValue(new Map([ + ['default', { + source: 'builtin', + config: { + name: 'default', + movements: [], + initialMovement: 'start', + maxIterations: 1, + }, + }], + ])); + mockGetPieceCategories.mockReturnValue({ + pieceCategories: [], + builtinPieceCategories: [], + userPieceCategories: [], + showOthersCategory: true, + othersCategoryName: 'Others', + }); + mockBuildCategorizedPieces.mockReturnValue({ + categories: [], + allPieces: new Map(), + missingPieces: [ + { categoryPath: ['Quick Start'], pieceName: 'default', source: 'builtin' }, + { categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' }, + ], + }); + mockSelectPieceFromCategorizedPieces.mockResolvedValue('default'); + + // When + const selected = await determinePiece('/project'); + + // Then + expect(selected).toBe('default'); + expect(mockWarnMissingPieces).toHaveBeenCalledWith([ + { categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' }, + ]); + }); }); diff --git a/src/__tests__/switchPiece.test.ts b/src/__tests__/switchPiece.test.ts new file mode 100644 index 0000000..5a86524 --- /dev/null +++ b/src/__tests__/switchPiece.test.ts @@ -0,0 +1,90 @@ +/** + * Tests for switchPiece behavior. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../infra/config/index.js', () => ({ + listPieceEntries: vi.fn(() => []), + loadAllPiecesWithSources: vi.fn(() => new Map()), + getPieceCategories: vi.fn(() => null), + buildCategorizedPieces: vi.fn(), + loadPiece: vi.fn(() => null), + getCurrentPiece: vi.fn(() => 'default'), + setCurrentPiece: vi.fn(), +})); + +vi.mock('../features/pieceSelection/index.js', () => ({ + warnMissingPieces: vi.fn(), + selectPieceFromCategorizedPieces: vi.fn(), + selectPieceFromEntries: vi.fn(), +})); + +vi.mock('../shared/ui/index.js', () => ({ + info: vi.fn(), + success: vi.fn(), + error: vi.fn(), +})); + +import { + loadAllPiecesWithSources, + getPieceCategories, + buildCategorizedPieces, +} from '../infra/config/index.js'; +import { + warnMissingPieces, + selectPieceFromCategorizedPieces, +} from '../features/pieceSelection/index.js'; +import { switchPiece } from '../features/config/switchPiece.js'; + +const mockLoadAllPiecesWithSources = vi.mocked(loadAllPiecesWithSources); +const mockGetPieceCategories = vi.mocked(getPieceCategories); +const mockBuildCategorizedPieces = vi.mocked(buildCategorizedPieces); +const mockWarnMissingPieces = vi.mocked(warnMissingPieces); +const mockSelectPieceFromCategorizedPieces = vi.mocked(selectPieceFromCategorizedPieces); + +describe('switchPiece', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should warn only user-origin missing pieces during interactive switch', async () => { + // Given + mockLoadAllPiecesWithSources.mockReturnValue(new Map([ + ['default', { + source: 'builtin', + config: { + name: 'default', + movements: [], + initialMovement: 'start', + maxIterations: 1, + }, + }], + ])); + mockGetPieceCategories.mockReturnValue({ + pieceCategories: [], + builtinPieceCategories: [], + userPieceCategories: [], + showOthersCategory: true, + othersCategoryName: 'Others', + }); + mockBuildCategorizedPieces.mockReturnValue({ + categories: [], + allPieces: new Map(), + missingPieces: [ + { categoryPath: ['Quick Start'], pieceName: 'default', source: 'builtin' }, + { categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' }, + ], + }); + mockSelectPieceFromCategorizedPieces.mockResolvedValue(null); + + // When + const switched = await switchPiece('/project'); + + // Then + expect(switched).toBe(false); + expect(mockWarnMissingPieces).toHaveBeenCalledWith([ + { categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' }, + ]); + }); +}); diff --git a/src/features/config/resetCategories.ts b/src/features/config/resetCategories.ts index ae15490..369d9cf 100644 --- a/src/features/config/resetCategories.ts +++ b/src/features/config/resetCategories.ts @@ -1,18 +1,16 @@ /** - * Reset piece categories to builtin defaults. + * Reset user piece categories overlay. */ -import { getDefaultCategoriesPath } from '../../infra/config/loaders/pieceCategories.js'; import { resetPieceCategories, getPieceCategoriesPath } from '../../infra/config/global/pieceCategories.js'; import { header, success, info } from '../../shared/ui/index.js'; export async function resetCategoriesToDefault(): Promise { header('Reset Categories'); - const defaultPath = getDefaultCategoriesPath(); - resetPieceCategories(defaultPath); + resetPieceCategories(); const userPath = getPieceCategoriesPath(); - success('Categories reset to builtin defaults.'); + success('User category overlay reset.'); info(` ${userPath}`); } diff --git a/src/features/config/switchPiece.ts b/src/features/config/switchPiece.ts index 7b9206c..5fab782 100644 --- a/src/features/config/switchPiece.ts +++ b/src/features/config/switchPiece.ts @@ -37,7 +37,7 @@ export async function switchPiece(cwd: string, pieceName?: string): Promise missing.source === 'user')); selected = await selectPieceFromCategorizedPieces(categorized, current); } } else { diff --git a/src/features/tasks/execute/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index 6ac3d23..94a7c52 100644 --- a/src/features/tasks/execute/selectAndExecute.ts +++ b/src/features/tasks/execute/selectAndExecute.ts @@ -68,7 +68,7 @@ async function selectPiece(cwd: string): Promise { return DEFAULT_PIECE_NAME; } const categorized = buildCategorizedPieces(allPieces, categoryConfig); - warnMissingPieces(categorized.missingPieces); + warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user')); return selectPieceFromCategorizedPieces(categorized, current); } return selectPieceWithDirectoryCategories(cwd); diff --git a/src/infra/config/global/index.ts b/src/infra/config/global/index.ts index e48ebc5..30b0b93 100644 --- a/src/infra/config/global/index.ts +++ b/src/infra/config/global/index.ts @@ -27,7 +27,6 @@ export { export { getPieceCategoriesPath, - ensureUserCategoriesFile, resetPieceCategories, } from './pieceCategories.js'; diff --git a/src/infra/config/global/pieceCategories.ts b/src/infra/config/global/pieceCategories.ts index 576b7bc..b189ab1 100644 --- a/src/infra/config/global/pieceCategories.ts +++ b/src/infra/config/global/pieceCategories.ts @@ -1,67 +1,38 @@ /** * Piece categories file management. * - * The categories file (~/.takt/preferences/piece-categories.yaml) uses the same - * format as the builtin piece-categories.yaml (piece_categories key). - * If the file doesn't exist, it's auto-copied from builtin defaults. + * User category file is treated as overlay on top of builtin categories. */ -import { existsSync, mkdirSync, copyFileSync } from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { getGlobalConfigDir } from '../paths.js'; import { loadGlobalConfig } from './globalConfig.js'; +const INITIAL_USER_CATEGORIES_CONTENT = 'piece_categories: {}\n'; + function getDefaultPieceCategoriesPath(): string { return join(getGlobalConfigDir(), 'preferences', 'piece-categories.yaml'); } /** Get the path to the user's piece categories file. */ export function getPieceCategoriesPath(): string { - try { - const config = loadGlobalConfig(); - if (config.pieceCategoriesFile) { - return config.pieceCategoriesFile; - } - } catch { - // Ignore errors, use default + const config = loadGlobalConfig(); + if (config.pieceCategoriesFile) { + return config.pieceCategoriesFile; } return getDefaultPieceCategoriesPath(); } /** - * Ensure user categories file exists by copying from builtin defaults. - * Returns the path to the user categories file. + * Reset user categories overlay file to initial content. */ -export function ensureUserCategoriesFile(defaultCategoriesPath: string): string { - const userPath = getPieceCategoriesPath(); - if (existsSync(userPath)) { - return userPath; - } - - if (!existsSync(defaultCategoriesPath)) { - throw new Error(`Default categories file not found: ${defaultCategoriesPath}`); - } - - const dir = dirname(userPath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - copyFileSync(defaultCategoriesPath, userPath); - return userPath; -} - -/** - * Reset user categories file by overwriting with builtin defaults. - */ -export function resetPieceCategories(defaultCategoriesPath: string): void { - if (!existsSync(defaultCategoriesPath)) { - throw new Error(`Default categories file not found: ${defaultCategoriesPath}`); - } - +export function resetPieceCategories(): void { const userPath = getPieceCategoriesPath(); const dir = dirname(userPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - copyFileSync(defaultCategoriesPath, userPath); + + writeFileSync(userPath, INITIAL_USER_CATEGORIES_CONTENT, 'utf-8'); } diff --git a/src/infra/config/loaders/pieceCategories.ts b/src/infra/config/loaders/pieceCategories.ts index f5669db..6bbd64b 100644 --- a/src/infra/config/loaders/pieceCategories.ts +++ b/src/infra/config/loaders/pieceCategories.ts @@ -1,8 +1,9 @@ /** * Piece category configuration loader and helpers. * - * Categories are loaded from a single source: the user's piece-categories.yaml file. - * If the file doesn't exist, it's auto-copied from builtin defaults. + * Categories are built from 2 layers: + * - builtin base categories (read-only) + * - user overlay categories (optional) */ import { existsSync, readFileSync } from 'node:fs'; @@ -10,7 +11,7 @@ import { join } from 'node:path'; import { parse as parseYaml } from 'yaml'; import { z } from 'zod/v4'; import { getLanguage, getBuiltinPiecesEnabled, getDisabledBuiltins } from '../global/globalConfig.js'; -import { ensureUserCategoriesFile } from '../global/pieceCategories.js'; +import { getPieceCategoriesPath } from '../global/pieceCategories.js'; import { getLanguageResourcesDir } from '../../resources/index.js'; import { listBuiltinPieceNames } from './pieceResolver.js'; import type { PieceWithSource } from './pieceResolver.js'; @@ -29,6 +30,8 @@ export interface PieceCategoryNode { export interface CategoryConfig { pieceCategories: PieceCategoryNode[]; + builtinPieceCategories: PieceCategoryNode[]; + userPieceCategories: PieceCategoryNode[]; showOthersCategory: boolean; othersCategoryName: string; } @@ -42,6 +45,7 @@ export interface CategorizedPieces { export interface MissingPiece { categoryPath: string[]; pieceName: string; + source: 'builtin' | 'user'; } interface RawCategoryConfig { @@ -50,6 +54,19 @@ interface RawCategoryConfig { others_category_name?: string; } +interface ParsedCategoryNode { + name: string; + pieces: string[]; + hasPieces: boolean; + children: ParsedCategoryNode[]; +} + +interface ParsedCategoryConfig { + pieceCategories?: ParsedCategoryNode[]; + showOthersCategory?: boolean; + othersCategoryName?: string; +} + function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } @@ -59,6 +76,7 @@ function parsePieces(raw: unknown, sourceLabel: string, path: string[]): string[ if (!Array.isArray(raw)) { throw new Error(`pieces must be an array in ${sourceLabel} at ${path.join(' > ')}`); } + const pieces: string[] = []; for (const item of raw) { if (typeof item !== 'string' || item.trim().length === 0) { @@ -74,13 +92,14 @@ function parseCategoryNode( raw: unknown, sourceLabel: string, path: string[], -): PieceCategoryNode { +): ParsedCategoryNode { if (!isRecord(raw)) { throw new Error(`category "${name}" must be an object in ${sourceLabel} at ${path.join(' > ')}`); } + const hasPieces = Object.prototype.hasOwnProperty.call(raw, 'pieces'); const pieces = parsePieces(raw.pieces, sourceLabel, path); - const children: PieceCategoryNode[] = []; + const children: ParsedCategoryNode[] = []; for (const [key, value] of Object.entries(raw)) { if (key === 'pieces') continue; @@ -90,59 +109,125 @@ function parseCategoryNode( children.push(parseCategoryNode(key, value, sourceLabel, [...path, key])); } - return { name, pieces, children }; + return { name, pieces, hasPieces, children }; } -function parseCategoryTree(raw: unknown, sourceLabel: string): PieceCategoryNode[] { +function parseCategoryTree(raw: unknown, sourceLabel: string): ParsedCategoryNode[] { if (!isRecord(raw)) { throw new Error(`piece_categories must be an object in ${sourceLabel}`); } - const categories: PieceCategoryNode[] = []; + + const categories: ParsedCategoryNode[] = []; for (const [name, value] of Object.entries(raw)) { categories.push(parseCategoryNode(name, value, sourceLabel, [name])); } return categories; } -function parseCategoryConfig(raw: unknown, sourceLabel: string): CategoryConfig | null { +function parseCategoryConfig(raw: unknown, sourceLabel: string): ParsedCategoryConfig | null { if (!raw || typeof raw !== 'object') { return null; } - const hasPieceCategories = Object.prototype.hasOwnProperty.call(raw, 'piece_categories'); - if (!hasPieceCategories) { + const parsed = CategoryConfigSchema.parse(raw) as RawCategoryConfig; + const hasPieceCategories = Object.prototype.hasOwnProperty.call(parsed, 'piece_categories'); + + const result: ParsedCategoryConfig = {}; + if (hasPieceCategories) { + if (!parsed.piece_categories) { + throw new Error(`piece_categories must be an object in ${sourceLabel}`); + } + result.pieceCategories = parseCategoryTree(parsed.piece_categories, sourceLabel); + } + + if (parsed.show_others_category !== undefined) { + result.showOthersCategory = parsed.show_others_category; + } + if (parsed.others_category_name !== undefined) { + result.othersCategoryName = parsed.others_category_name; + } + + if ( + result.pieceCategories === undefined + && result.showOthersCategory === undefined + && result.othersCategoryName === undefined + ) { return null; } - const parsed = CategoryConfigSchema.parse(raw) as RawCategoryConfig; - if (!parsed.piece_categories) { - throw new Error(`piece_categories is required in ${sourceLabel}`); - } - - const showOthersCategory = parsed.show_others_category === undefined - ? true - : parsed.show_others_category; - - const othersCategoryName = parsed.others_category_name === undefined - ? 'Others' - : parsed.others_category_name; - - return { - pieceCategories: parseCategoryTree(parsed.piece_categories, sourceLabel), - showOthersCategory, - othersCategoryName, - }; + return result; } -function loadCategoryConfigFromPath(path: string, sourceLabel: string): CategoryConfig | null { +function loadCategoryConfigFromPath(path: string, sourceLabel: string): ParsedCategoryConfig | null { if (!existsSync(path)) { return null; } + const content = readFileSync(path, 'utf-8'); const raw = parseYaml(content); return parseCategoryConfig(raw, sourceLabel); } +function convertParsedNodes(nodes: ParsedCategoryNode[]): PieceCategoryNode[] { + return nodes.map((node) => ({ + name: node.name, + pieces: node.pieces, + children: convertParsedNodes(node.children), + })); +} + +function mergeCategoryNodes(baseNodes: ParsedCategoryNode[], overlayNodes: ParsedCategoryNode[]): ParsedCategoryNode[] { + const overlayByName = new Map(); + for (const overlayNode of overlayNodes) { + overlayByName.set(overlayNode.name, overlayNode); + } + + const merged: ParsedCategoryNode[] = []; + for (const baseNode of baseNodes) { + const overlayNode = overlayByName.get(baseNode.name); + if (!overlayNode) { + merged.push(baseNode); + continue; + } + + overlayByName.delete(baseNode.name); + + const mergedNode: ParsedCategoryNode = { + name: baseNode.name, + pieces: overlayNode.hasPieces ? overlayNode.pieces : baseNode.pieces, + hasPieces: baseNode.hasPieces || overlayNode.hasPieces, + children: mergeCategoryNodes(baseNode.children, overlayNode.children), + }; + merged.push(mergedNode); + } + + for (const overlayNode of overlayByName.values()) { + merged.push(overlayNode); + } + + return merged; +} + +function resolveShowOthersCategory(defaultConfig: ParsedCategoryConfig, userConfig: ParsedCategoryConfig | null): boolean { + if (userConfig?.showOthersCategory !== undefined) { + return userConfig.showOthersCategory; + } + if (defaultConfig.showOthersCategory !== undefined) { + return defaultConfig.showOthersCategory; + } + return true; +} + +function resolveOthersCategoryName(defaultConfig: ParsedCategoryConfig, userConfig: ParsedCategoryConfig | null): string { + if (userConfig?.othersCategoryName !== undefined) { + return userConfig.othersCategoryName; + } + if (defaultConfig.othersCategoryName !== undefined) { + return defaultConfig.othersCategoryName; + } + return 'Others'; +} + /** * Load default categories from builtin resource file. * Returns null if file doesn't exist or has no piece_categories. @@ -150,7 +235,23 @@ function loadCategoryConfigFromPath(path: string, sourceLabel: string): Category export function loadDefaultCategories(): CategoryConfig | null { const lang = getLanguage(); const filePath = join(getLanguageResourcesDir(lang), 'piece-categories.yaml'); - return loadCategoryConfigFromPath(filePath, filePath); + const parsed = loadCategoryConfigFromPath(filePath, filePath); + + if (!parsed?.pieceCategories) { + return null; + } + + const builtinPieceCategories = convertParsedNodes(parsed.pieceCategories); + const showOthersCategory = parsed.showOthersCategory ?? true; + const othersCategoryName = parsed.othersCategoryName ?? 'Others'; + + return { + pieceCategories: builtinPieceCategories, + builtinPieceCategories, + userPieceCategories: [], + showOthersCategory, + othersCategoryName, + }; } /** Get the path to the builtin default categories file. */ @@ -161,28 +262,49 @@ export function getDefaultCategoriesPath(): string { /** * Get effective piece categories configuration. - * Reads from user file (~/.takt/preferences/piece-categories.yaml). - * Auto-copies from builtin defaults if user file doesn't exist. + * Built from builtin categories and optional user overlay. */ export function getPieceCategories(): CategoryConfig | null { const defaultPath = getDefaultCategoriesPath(); - const userPath = ensureUserCategoriesFile(defaultPath); - return loadCategoryConfigFromPath(userPath, userPath); + const defaultConfig = loadCategoryConfigFromPath(defaultPath, defaultPath); + if (!defaultConfig?.pieceCategories) { + return null; + } + + const userPath = getPieceCategoriesPath(); + const userConfig = loadCategoryConfigFromPath(userPath, userPath); + + const merged = userConfig?.pieceCategories + ? mergeCategoryNodes(defaultConfig.pieceCategories, userConfig.pieceCategories) + : defaultConfig.pieceCategories; + + const builtinPieceCategories = convertParsedNodes(defaultConfig.pieceCategories); + const userPieceCategories = convertParsedNodes(userConfig?.pieceCategories ?? []); + + return { + pieceCategories: convertParsedNodes(merged), + builtinPieceCategories, + userPieceCategories, + showOthersCategory: resolveShowOthersCategory(defaultConfig, userConfig), + othersCategoryName: resolveOthersCategoryName(defaultConfig, userConfig), + }; } function collectMissingPieces( categories: PieceCategoryNode[], allPieces: Map, ignorePieces: Set, + source: 'builtin' | 'user', ): MissingPiece[] { const missing: MissingPiece[] = []; + const visit = (nodes: PieceCategoryNode[], path: string[]): void => { for (const node of nodes) { const nextPath = [...path, node.name]; for (const pieceName of node.pieces) { if (ignorePieces.has(pieceName)) continue; if (!allPieces.has(pieceName)) { - missing.push({ categoryPath: nextPath, pieceName }); + missing.push({ categoryPath: nextPath, pieceName, source }); } } if (node.children.length > 0) { @@ -235,7 +357,6 @@ function appendOthersCategory( return categories; } - // If a category with the same name already exists, merge uncategorized pieces into it const existingIndex = categories.findIndex((node) => node.name === othersCategoryName); if (existingIndex >= 0) { const existing = categories[existingIndex]!; @@ -250,8 +371,7 @@ function appendOthersCategory( } /** - * Build categorized pieces map from configuration. - * All pieces (user and builtin) are placed in a single category tree. + * Build categorized pieces map from effective configuration. */ export function buildCategorizedPieces( allPieces: Map, @@ -268,18 +388,13 @@ export function buildCategorizedPieces( } } - const missingPieces = collectMissingPieces( - config.pieceCategories, - allPieces, - ignoreMissing, - ); + const missingPieces = [ + ...collectMissingPieces(config.builtinPieceCategories, allPieces, ignoreMissing, 'builtin'), + ...collectMissingPieces(config.userPieceCategories, allPieces, ignoreMissing, 'user'), + ]; const categorized = new Set(); - const categories = buildCategoryTree( - config.pieceCategories, - allPieces, - categorized, - ); + const categories = buildCategoryTree(config.pieceCategories, allPieces, categorized); const finalCategories = config.showOthersCategory ? appendOthersCategory(categories, allPieces, categorized, config.othersCategoryName)