@aladac/hu 0.1.0-a1 → 0.1.0-a2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ encodeProjectPath,
4
+ decodeProjectPath,
5
+ getProjectsDir,
6
+ getHistoryPath,
7
+ getStatsPath,
8
+ getTodosDir,
9
+ } from '../../src/lib/claude-paths.ts';
10
+
11
+ describe('claude-paths', () => {
12
+ describe('encodeProjectPath / decodeProjectPath', () => {
13
+ it('encodes simple paths by replacing / with -', () => {
14
+ const testPath = '/Users/chi/Projects/myapp';
15
+ const encoded = encodeProjectPath(testPath);
16
+ expect(encoded).toBe('-Users-chi-Projects-myapp');
17
+ });
18
+
19
+ it('decodes paths by replacing - with /', () => {
20
+ const encoded = '-Users-chi-Projects-myapp';
21
+ const decoded = decodeProjectPath(encoded);
22
+ expect(decoded).toBe('/Users/chi/Projects/myapp');
23
+ });
24
+
25
+ it('matches Claude Code directory naming convention', () => {
26
+ // Real examples from Claude Code
27
+ expect(decodeProjectPath('-Users-chi')).toBe('/Users/chi');
28
+ expect(decodeProjectPath('-Users-chi--claude')).toBe('/Users/chi/.claude');
29
+ expect(decodeProjectPath('-Users-chi-Desktop-new-json')).toBe('/Users/chi/Desktop/new/json');
30
+ });
31
+
32
+ it('handles paths with unicode', () => {
33
+ const testPath = '/Users/chi/Projects/日本語';
34
+ const encoded = encodeProjectPath(testPath);
35
+ expect(encoded).toBe('-Users-chi-Projects-日本語');
36
+ });
37
+
38
+ it('handles paths with spaces', () => {
39
+ const testPath = '/Users/chi/My Projects/Some App';
40
+ const encoded = encodeProjectPath(testPath);
41
+ expect(encoded).toBe('-Users-chi-My Projects-Some App');
42
+ });
43
+
44
+ it('produces directory-safe encoded strings', () => {
45
+ const testPath = '/Users/chi/Projects/myapp';
46
+ const encoded = encodeProjectPath(testPath);
47
+ // Should not contain /
48
+ expect(encoded).not.toContain('/');
49
+ });
50
+ });
51
+
52
+ describe('path getters', () => {
53
+ it('getProjectsDir returns path ending with projects', () => {
54
+ const projectsDir = getProjectsDir();
55
+ expect(projectsDir).toContain('projects');
56
+ });
57
+
58
+ it('getHistoryPath returns history.jsonl path', () => {
59
+ const historyPath = getHistoryPath();
60
+ expect(historyPath).toMatch(/history\.jsonl$/);
61
+ });
62
+
63
+ it('getStatsPath returns stats-cache.json path', () => {
64
+ const statsPath = getStatsPath();
65
+ expect(statsPath).toMatch(/stats-cache\.json$/);
66
+ });
67
+
68
+ it('getTodosDir returns todos directory path', () => {
69
+ const todosDir = getTodosDir();
70
+ expect(todosDir).toContain('todos');
71
+ });
72
+ });
73
+ });
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+ import {
6
+ expandPath,
7
+ getConfigDir,
8
+ getConfigPath,
9
+ getDatabasePath,
10
+ ensureConfig,
11
+ getConfig,
12
+ getClaudeDir,
13
+ clearConfigCache,
14
+ type HuConfig,
15
+ } from '../../src/lib/config.ts';
16
+
17
+ describe('config', () => {
18
+ let originalConfigContent: string | null = null;
19
+ const configPath = getConfigPath();
20
+
21
+ beforeEach(() => {
22
+ clearConfigCache();
23
+ // Save original config if it exists
24
+ if (fs.existsSync(configPath)) {
25
+ originalConfigContent = fs.readFileSync(configPath, 'utf-8');
26
+ }
27
+ });
28
+
29
+ afterEach(() => {
30
+ clearConfigCache();
31
+ // Restore original config
32
+ if (originalConfigContent !== null) {
33
+ fs.writeFileSync(configPath, originalConfigContent, 'utf-8');
34
+ }
35
+ });
36
+
37
+ describe('expandPath', () => {
38
+ it('expands ~ to home directory', () => {
39
+ const home = os.homedir();
40
+ expect(expandPath('~')).toBe(home);
41
+ expect(expandPath('~/')).toBe(home);
42
+ expect(expandPath('~/foo/bar')).toBe(path.join(home, 'foo/bar'));
43
+ });
44
+
45
+ it('leaves absolute paths unchanged', () => {
46
+ expect(expandPath('/foo/bar')).toBe('/foo/bar');
47
+ expect(expandPath('/tmp')).toBe('/tmp');
48
+ });
49
+
50
+ it('leaves relative paths unchanged', () => {
51
+ expect(expandPath('foo/bar')).toBe('foo/bar');
52
+ expect(expandPath('./foo')).toBe('./foo');
53
+ });
54
+ });
55
+
56
+ describe('getConfig', () => {
57
+ it('creates config directory and file if missing', () => {
58
+ ensureConfig();
59
+ expect(fs.existsSync(getConfigDir())).toBe(true);
60
+ expect(fs.existsSync(configPath)).toBe(true);
61
+ });
62
+
63
+ it('returns config with all required sections', () => {
64
+ const config = getConfig();
65
+ expect(config.general).toBeDefined();
66
+ expect(config.sync).toBeDefined();
67
+ expect(config.hooks).toBeDefined();
68
+ expect(config.search).toBeDefined();
69
+ expect(config.output).toBeDefined();
70
+ });
71
+
72
+ it('caches config and returns same object', () => {
73
+ const config1 = getConfig();
74
+ const config2 = getConfig();
75
+ expect(config1).toBe(config2);
76
+ });
77
+
78
+ it('reloads config when forceReload is true', () => {
79
+ const config1 = getConfig();
80
+ const config2 = getConfig(true);
81
+ expect(config1).not.toBe(config2);
82
+ });
83
+
84
+ it('merges partial config with defaults', () => {
85
+ // Write partial config
86
+ fs.writeFileSync(configPath, `
87
+ [search]
88
+ default_limit = 50
89
+ `, 'utf-8');
90
+
91
+ clearConfigCache();
92
+ const config = getConfig();
93
+
94
+ // Custom value
95
+ expect(config.search.default_limit).toBe(50);
96
+
97
+ // Default values preserved
98
+ expect(config.general.database).toBe('hu.db');
99
+ expect(config.sync.auto_sync_interval).toBe(300);
100
+ expect(config.hooks.enabled).toBe(true);
101
+ });
102
+
103
+ it('handles invalid TOML gracefully', () => {
104
+ // Write invalid TOML
105
+ fs.writeFileSync(configPath, 'invalid = [toml', 'utf-8');
106
+
107
+ clearConfigCache();
108
+ const config = getConfig();
109
+
110
+ // Should return defaults
111
+ expect(config.general.database).toBe('hu.db');
112
+ });
113
+ });
114
+
115
+ describe('getDatabasePath', () => {
116
+ it('resolves relative path to config dir', () => {
117
+ const config: HuConfig = {
118
+ ...getConfig(),
119
+ general: { claude_dir: '~/.claude', database: 'hu.db' },
120
+ };
121
+ const dbPath = getDatabasePath(config);
122
+ expect(dbPath).toBe(path.join(getConfigDir(), 'hu.db'));
123
+ });
124
+
125
+ it('expands ~ in database path', () => {
126
+ const config: HuConfig = {
127
+ ...getConfig(),
128
+ general: { claude_dir: '~/.claude', database: '~/data/hu.db' },
129
+ };
130
+ const dbPath = getDatabasePath(config);
131
+ expect(dbPath).toBe(path.join(os.homedir(), 'data/hu.db'));
132
+ });
133
+
134
+ it('uses absolute path as-is', () => {
135
+ const config: HuConfig = {
136
+ ...getConfig(),
137
+ general: { claude_dir: '~/.claude', database: '/var/lib/hu/hu.db' },
138
+ };
139
+ const dbPath = getDatabasePath(config);
140
+ expect(dbPath).toBe('/var/lib/hu/hu.db');
141
+ });
142
+ });
143
+
144
+ describe('getClaudeDir', () => {
145
+ it('expands ~ in claude_dir', () => {
146
+ const config: HuConfig = {
147
+ ...getConfig(),
148
+ general: { claude_dir: '~/.claude', database: 'hu.db' },
149
+ };
150
+ const claudeDir = getClaudeDir(config);
151
+ expect(claudeDir).toBe(path.join(os.homedir(), '.claude'));
152
+ });
153
+
154
+ it('uses absolute path as-is', () => {
155
+ const config: HuConfig = {
156
+ ...getConfig(),
157
+ general: { claude_dir: '/custom/claude', database: 'hu.db' },
158
+ };
159
+ const claudeDir = getClaudeDir(config);
160
+ expect(claudeDir).toBe('/custom/claude');
161
+ });
162
+ });
163
+ });
@@ -0,0 +1,230 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import Database from 'better-sqlite3';
3
+ import * as fs from 'node:fs';
4
+ import * as path from 'node:path';
5
+ import * as os from 'node:os';
6
+ import { initializeSchema, getSchemaVersion } from '../../src/lib/schema.ts';
7
+
8
+ describe('database', () => {
9
+ let tempDir: string;
10
+ let dbPath: string;
11
+ let db: Database.Database;
12
+
13
+ beforeEach(() => {
14
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hu-db-test-'));
15
+ dbPath = path.join(tempDir, 'test.db');
16
+ db = new Database(dbPath);
17
+ db.pragma('journal_mode = WAL');
18
+ db.pragma('foreign_keys = ON');
19
+ });
20
+
21
+ afterEach(() => {
22
+ db.close();
23
+ try {
24
+ fs.rmSync(tempDir, { recursive: true });
25
+ } catch {
26
+ // Ignore cleanup errors
27
+ }
28
+ });
29
+
30
+ describe('schema initialization', () => {
31
+ it('creates all required tables', () => {
32
+ initializeSchema(db);
33
+
34
+ const tables = db.prepare(`
35
+ SELECT name FROM sqlite_master WHERE type='table' ORDER BY name
36
+ `).all() as Array<{ name: string }>;
37
+
38
+ const tableNames = tables.map(t => t.name);
39
+ expect(tableNames).toContain('sessions');
40
+ expect(tableNames).toContain('messages');
41
+ expect(tableNames).toContain('todos');
42
+ expect(tableNames).toContain('tool_usage');
43
+ expect(tableNames).toContain('sync_state');
44
+ expect(tableNames).toContain('schema_version');
45
+ });
46
+
47
+ it('creates FTS5 virtual table', () => {
48
+ initializeSchema(db);
49
+
50
+ const tables = db.prepare(`
51
+ SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%fts%'
52
+ `).all() as Array<{ name: string }>;
53
+
54
+ expect(tables.some(t => t.name === 'messages_fts')).toBe(true);
55
+ });
56
+
57
+ it('tracks schema version', () => {
58
+ initializeSchema(db);
59
+ const version = getSchemaVersion(db);
60
+ expect(version).toBeGreaterThan(0);
61
+ });
62
+
63
+ it('is idempotent (can run multiple times)', () => {
64
+ initializeSchema(db);
65
+ const version1 = getSchemaVersion(db);
66
+
67
+ initializeSchema(db);
68
+ const version2 = getSchemaVersion(db);
69
+
70
+ expect(version2).toBe(version1);
71
+ });
72
+ });
73
+
74
+ describe('data operations', () => {
75
+ beforeEach(() => {
76
+ initializeSchema(db);
77
+ });
78
+
79
+ it('inserts and retrieves sessions', () => {
80
+ db.prepare(`
81
+ INSERT INTO sessions (id, project, started_at)
82
+ VALUES (?, ?, ?)
83
+ `).run('test-session-1', '/Users/test/project', Date.now());
84
+
85
+ const session = db.prepare('SELECT * FROM sessions WHERE id = ?')
86
+ .get('test-session-1') as { id: string; project: string };
87
+
88
+ expect(session.id).toBe('test-session-1');
89
+ expect(session.project).toBe('/Users/test/project');
90
+ });
91
+
92
+ it('handles unicode in content', () => {
93
+ const unicodeContent = '日本語テスト 🎉 مرحبا';
94
+
95
+ db.prepare(`
96
+ INSERT INTO sessions (id, project, started_at)
97
+ VALUES (?, ?, ?)
98
+ `).run('unicode-session', '/test', Date.now());
99
+
100
+ db.prepare(`
101
+ INSERT INTO messages (id, session_id, role, content, created_at)
102
+ VALUES (?, ?, ?, ?, ?)
103
+ `).run('msg-1', 'unicode-session', 'user', unicodeContent, Date.now());
104
+
105
+ const message = db.prepare('SELECT content FROM messages WHERE id = ?')
106
+ .get('msg-1') as { content: string };
107
+
108
+ expect(message.content).toBe(unicodeContent);
109
+ });
110
+
111
+ it('handles special characters in content', () => {
112
+ const specialContent = 'Quotes: "hello" and \'world\'\nBackslash: C:\\path\nDollar: $HOME';
113
+
114
+ db.prepare(`
115
+ INSERT INTO sessions (id, project, started_at)
116
+ VALUES (?, ?, ?)
117
+ `).run('special-session', '/test', Date.now());
118
+
119
+ db.prepare(`
120
+ INSERT INTO messages (id, session_id, role, content, created_at)
121
+ VALUES (?, ?, ?, ?, ?)
122
+ `).run('msg-special', 'special-session', 'user', specialContent, Date.now());
123
+
124
+ const message = db.prepare('SELECT content FROM messages WHERE id = ?')
125
+ .get('msg-special') as { content: string };
126
+
127
+ expect(message.content).toBe(specialContent);
128
+ });
129
+
130
+ it('enforces foreign key constraints', () => {
131
+ expect(() => {
132
+ db.prepare(`
133
+ INSERT INTO messages (id, session_id, role, content, created_at)
134
+ VALUES (?, ?, ?, ?, ?)
135
+ `).run('orphan-msg', 'nonexistent-session', 'user', 'test', Date.now());
136
+ }).toThrow(/FOREIGN KEY/);
137
+ });
138
+
139
+ it('supports transactions', () => {
140
+ const insertMany = db.transaction((items: Array<{ id: string; project: string }>) => {
141
+ for (const item of items) {
142
+ db.prepare(`
143
+ INSERT INTO sessions (id, project, started_at)
144
+ VALUES (?, ?, ?)
145
+ `).run(item.id, item.project, Date.now());
146
+ }
147
+ });
148
+
149
+ insertMany([
150
+ { id: 'tx-1', project: '/a' },
151
+ { id: 'tx-2', project: '/b' },
152
+ { id: 'tx-3', project: '/c' },
153
+ ]);
154
+
155
+ const count = db.prepare('SELECT COUNT(*) as count FROM sessions')
156
+ .get() as { count: number };
157
+
158
+ expect(count.count).toBe(3);
159
+ });
160
+
161
+ it('rolls back transaction on error', () => {
162
+ const insertWithError = db.transaction(() => {
163
+ db.prepare(`
164
+ INSERT INTO sessions (id, project, started_at)
165
+ VALUES (?, ?, ?)
166
+ `).run('rollback-test', '/test', Date.now());
167
+
168
+ // This will fail due to duplicate primary key
169
+ db.prepare(`
170
+ INSERT INTO sessions (id, project, started_at)
171
+ VALUES (?, ?, ?)
172
+ `).run('rollback-test', '/test2', Date.now());
173
+ });
174
+
175
+ expect(() => insertWithError()).toThrow();
176
+
177
+ const count = db.prepare('SELECT COUNT(*) as count FROM sessions WHERE id = ?')
178
+ .get('rollback-test') as { count: number };
179
+
180
+ expect(count.count).toBe(0);
181
+ });
182
+ });
183
+
184
+ describe('FTS5 search', () => {
185
+ beforeEach(() => {
186
+ initializeSchema(db);
187
+
188
+ // Insert test data
189
+ db.prepare(`
190
+ INSERT INTO sessions (id, project, started_at)
191
+ VALUES (?, ?, ?)
192
+ `).run('fts-session', '/test', Date.now());
193
+
194
+ const messages = [
195
+ { id: 'fts-1', content: 'Hello world this is a test' },
196
+ { id: 'fts-2', content: 'Another message with different content' },
197
+ { id: 'fts-3', content: 'Testing unicode: 日本語 and emoji 🎉' },
198
+ ];
199
+
200
+ for (const msg of messages) {
201
+ db.prepare(`
202
+ INSERT INTO messages (id, session_id, role, content, created_at)
203
+ VALUES (?, ?, ?, ?, ?)
204
+ `).run(msg.id, 'fts-session', 'user', msg.content, Date.now());
205
+ }
206
+ });
207
+
208
+ it('finds messages by keyword', () => {
209
+ const results = db.prepare(`
210
+ SELECT m.* FROM messages m
211
+ JOIN messages_fts fts ON m.rowid = fts.rowid
212
+ WHERE messages_fts MATCH ?
213
+ `).all('hello') as Array<{ id: string }>;
214
+
215
+ expect(results.length).toBe(1);
216
+ expect(results[0].id).toBe('fts-1');
217
+ });
218
+
219
+ it('finds messages with unicode content', () => {
220
+ const results = db.prepare(`
221
+ SELECT m.* FROM messages m
222
+ JOIN messages_fts fts ON m.rowid = fts.rowid
223
+ WHERE messages_fts MATCH ?
224
+ `).all('日本語') as Array<{ id: string }>;
225
+
226
+ expect(results.length).toBe(1);
227
+ expect(results[0].id).toBe('fts-3');
228
+ });
229
+ });
230
+ });