takt: update-category-spec (#184)

This commit is contained in:
nrs 2026-02-09 23:30:17 +09:00 committed by GitHub
parent f8bcc4ce7d
commit c7305374d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 648 additions and 228 deletions

View File

@ -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');
});
});

View File

@ -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<string, unknown>;
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,28 +277,10 @@ describe('buildCategorizedPieces', () => {
pieceCategories: [
{ name: 'Main', pieces: ['default'], children: [] },
],
showOthersCategory: true,
othersCategoryName: 'Others',
};
const categorized = buildCategorizedPieces(allPieces, config);
expect(categorized.categories).toEqual([
builtinPieceCategories: [
{ name: 'Main', pieces: ['default'], children: [] },
{ name: 'Others', pieces: ['extra'], children: [] },
]);
});
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: [] },
],
userPieceCategories: [],
showOthersCategory: true,
othersCategoryName: 'Others',
};
@ -238,7 +288,7 @@ describe('buildCategorizedPieces', () => {
const categorized = buildCategorizedPieces(allPieces, config);
expect(categorized.categories).toEqual([
{ name: 'Main', pieces: ['default'], children: [] },
{ name: 'Others', pieces: ['extra', 'user-piece'], children: [] },
{ name: 'Others', pieces: ['extra'], children: [] },
]);
});
@ -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');
});
});

View File

@ -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');
});
});

View File

@ -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' },
]);
});
});

View File

@ -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' },
]);
});
});

View File

@ -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<void> {
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}`);
}

View File

@ -37,7 +37,7 @@ export async function switchPiece(cwd: string, pieceName?: string): Promise<bool
selected = null;
} else {
const categorized = buildCategorizedPieces(allPieces, categoryConfig);
warnMissingPieces(categorized.missingPieces);
warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user'));
selected = await selectPieceFromCategorizedPieces(categorized, current);
}
} else {

View File

@ -68,7 +68,7 @@ async function selectPiece(cwd: string): Promise<string | null> {
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);

View File

@ -27,7 +27,6 @@ export {
export {
getPieceCategoriesPath,
ensureUserCategoriesFile,
resetPieceCategories,
} from './pieceCategories.js';

View File

@ -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
}
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');
}

View File

@ -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<string, unknown> {
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<string, ParsedCategoryNode>();
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<string, PieceWithSource>,
ignorePieces: Set<string>,
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<string, PieceWithSource>,
@ -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<string>();
const categories = buildCategoryTree(
config.pieceCategories,
allPieces,
categorized,
);
const categories = buildCategoryTree(config.pieceCategories, allPieces, categorized);
const finalCategories = config.showOthersCategory
? appendOthersCategory(categories, allPieces, categorized, config.othersCategoryName)