Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions packages/cli/src/commands/explore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ explore
.description('Find code similar to a file')
.argument('<file>', 'File path')
.option('-l, --limit <number>', 'Number of results', '5')
.option('-t, --threshold <number>', 'Similarity threshold (0-1)', '0.5')
.action(async (file: string, options) => {
const spinner = ora('Finding similar code...').start();

Expand All @@ -84,19 +85,34 @@ explore
return;
}

// Prepare file for search (read content, resolve paths)
spinner.text = 'Reading file content...';
const { prepareFileForSearch } = await import('../utils/file.js');

let fileInfo: Awaited<ReturnType<typeof prepareFileForSearch>>;
try {
fileInfo = await prepareFileForSearch(config.repositoryPath, file);
} catch (error) {
spinner.fail((error as Error).message);
process.exit(1);
return;
}

const indexer = new RepositoryIndexer(config);
await indexer.initialize();

const results = await indexer.search(file, {
// Search using file content, not filename
spinner.text = 'Searching for similar code...';
const results = await indexer.search(fileInfo.content, {
limit: Number.parseInt(options.limit, 10) + 1,
scoreThreshold: 0.7,
scoreThreshold: Number.parseFloat(options.threshold),
});

// Filter out the file itself
// Filter out the file itself (exact path match)
const similar = results
.filter((r) => {
const meta = r.metadata as { path: string };
return !meta.path.includes(file);
return meta.path !== fileInfo.relativePath;
})
.slice(0, Number.parseInt(options.limit, 10));

Expand Down
237 changes: 237 additions & 0 deletions packages/cli/src/utils/file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
/**
* Unit tests for file utilities
* Target: 100% coverage for pure utility functions
*/

import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { normalizeFilePath, prepareFileForSearch, readFileContent, resolveFilePath } from './file';

describe('File Utilities', () => {
describe('resolveFilePath', () => {
it('should resolve relative path to absolute', () => {
const repoPath = '/home/user/project';
const filePath = 'src/index.ts';

const result = resolveFilePath(repoPath, filePath);

expect(result).toBe('/home/user/project/src/index.ts');
});

it('should handle already absolute paths', () => {
const repoPath = '/home/user/project';
const filePath = '/home/user/project/src/index.ts';

const result = resolveFilePath(repoPath, filePath);

expect(result).toBe('/home/user/project/src/index.ts');
});

it('should handle paths with ../', () => {
const repoPath = '/home/user/project';
const filePath = 'src/../lib/utils.ts';

const result = resolveFilePath(repoPath, filePath);

expect(result).toBe('/home/user/project/lib/utils.ts');
});

it('should handle current directory', () => {
const repoPath = '/home/user/project';
const filePath = './src/index.ts';

const result = resolveFilePath(repoPath, filePath);

expect(result).toBe('/home/user/project/src/index.ts');
});
});

describe('normalizeFilePath', () => {
it('should create relative path from absolute', () => {
const repoPath = '/home/user/project';
const absolutePath = '/home/user/project/src/index.ts';

const result = normalizeFilePath(repoPath, absolutePath);

expect(result).toBe('src/index.ts');
});

it('should handle paths in subdirectories', () => {
const repoPath = '/home/user/project';
const absolutePath = '/home/user/project/packages/core/src/index.ts';

const result = normalizeFilePath(repoPath, absolutePath);

expect(result).toBe('packages/core/src/index.ts');
});

it('should handle same path', () => {
const repoPath = '/home/user/project';
const absolutePath = '/home/user/project';

const result = normalizeFilePath(repoPath, absolutePath);

expect(result).toBe('');
});

it('should handle paths outside repository', () => {
const repoPath = '/home/user/project';
const absolutePath = '/home/user/other/file.ts';

const result = normalizeFilePath(repoPath, absolutePath);

expect(result).toContain('..');
});
});

describe('readFileContent', () => {
let tempDir: string;
let testFile: string;

beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'file-test-'));
testFile = path.join(tempDir, 'test.txt');
});

afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});

it('should read file content', async () => {
const content = 'Hello, World!';
await fs.writeFile(testFile, content);

const result = await readFileContent(testFile);

expect(result).toBe(content);
});

it('should read multiline content', async () => {
const content = 'Line 1\nLine 2\nLine 3';
await fs.writeFile(testFile, content);

const result = await readFileContent(testFile);

expect(result).toBe(content);
});

it('should throw error for non-existent file', async () => {
const nonExistent = path.join(tempDir, 'does-not-exist.txt');

await expect(readFileContent(nonExistent)).rejects.toThrow('File not found');
});

it('should throw error for empty file', async () => {
await fs.writeFile(testFile, '');

await expect(readFileContent(testFile)).rejects.toThrow('File is empty');
});

it('should throw error for whitespace-only file', async () => {
await fs.writeFile(testFile, ' \n \t \n ');

await expect(readFileContent(testFile)).rejects.toThrow('File is empty');
});

it('should handle files with leading/trailing whitespace', async () => {
const content = ' \n Content \n ';
await fs.writeFile(testFile, content);

const result = await readFileContent(testFile);

expect(result).toBe(content);
expect(result.trim()).toBe('Content');
});

it('should handle large files', async () => {
const content = 'x'.repeat(10000);
await fs.writeFile(testFile, content);

const result = await readFileContent(testFile);

expect(result.length).toBe(10000);
});

it('should handle files with special characters', async () => {
const content = 'Hello πŸš€ World\nδΈ­ζ–‡\nΞ¨';
await fs.writeFile(testFile, content, 'utf-8');

const result = await readFileContent(testFile);

expect(result).toBe(content);
});
});

describe('prepareFileForSearch', () => {
let tempDir: string;
let testFile: string;

beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'file-test-'));
testFile = path.join(tempDir, 'test.txt');
});

afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});

it('should prepare file for search', async () => {
const content = 'Test content';
await fs.writeFile(testFile, content);

const result = await prepareFileForSearch(tempDir, 'test.txt');

expect(result.content).toBe(content);
expect(result.absolutePath).toBe(testFile);
expect(result.relativePath).toBe('test.txt');
});

it('should handle nested directories', async () => {
const subDir = path.join(tempDir, 'src', 'utils');
await fs.mkdir(subDir, { recursive: true });
const nestedFile = path.join(subDir, 'helper.ts');
await fs.writeFile(nestedFile, 'export function helper() {}');

const result = await prepareFileForSearch(tempDir, 'src/utils/helper.ts');

expect(result.content).toContain('helper');
expect(result.relativePath).toBe('src/utils/helper.ts');
});

it('should return correct FileContentResult structure', async () => {
await fs.writeFile(testFile, 'content');

const result = await prepareFileForSearch(tempDir, 'test.txt');

expect(result).toHaveProperty('content');
expect(result).toHaveProperty('absolutePath');
expect(result).toHaveProperty('relativePath');
expect(typeof result.content).toBe('string');
expect(typeof result.absolutePath).toBe('string');
expect(typeof result.relativePath).toBe('string');
});

it('should throw error for non-existent file', async () => {
await expect(prepareFileForSearch(tempDir, 'nonexistent.txt')).rejects.toThrow(
'File not found'
);
});

it('should throw error for empty file', async () => {
await fs.writeFile(testFile, '');

await expect(prepareFileForSearch(tempDir, 'test.txt')).rejects.toThrow('File is empty');
});

it('should handle absolute path input', async () => {
await fs.writeFile(testFile, 'content');

const result = await prepareFileForSearch(tempDir, testFile);

expect(result.content).toBe('content');
expect(result.relativePath).toBe('test.txt');
});
});
});
72 changes: 72 additions & 0 deletions packages/cli/src/utils/file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* File utility functions for CLI commands
* Pure functions for file operations and validation
*/

import * as fs from 'node:fs/promises';
import * as path from 'node:path';

/**
* Resolve a file path relative to the repository root
*/
export function resolveFilePath(repositoryPath: string, filePath: string): string {
return path.resolve(repositoryPath, filePath);
}

/**
* Normalize a file path to be relative to repository root
*/
export function normalizeFilePath(repositoryPath: string, absolutePath: string): string {
return path.relative(repositoryPath, absolutePath);
}

/**
* Read and validate file content
* @throws Error if file doesn't exist or is empty
*/
export async function readFileContent(filePath: string): Promise<string> {
// Check if file exists
try {
await fs.access(filePath);
} catch {
throw new Error(`File not found: ${filePath}`);
}

// Read file content
const content = await fs.readFile(filePath, 'utf-8');

// Validate content
if (content.trim().length === 0) {
throw new Error(`File is empty: ${filePath}`);
}

return content;
}

/**
* Result of reading file for similarity search
*/
export interface FileContentResult {
content: string;
absolutePath: string;
relativePath: string;
}

/**
* Prepare a file for similarity search
* Resolves path, reads content, and normalizes paths
*/
export async function prepareFileForSearch(
repositoryPath: string,
filePath: string
): Promise<FileContentResult> {
const absolutePath = resolveFilePath(repositoryPath, filePath);
const content = await readFileContent(absolutePath);
const relativePath = normalizeFilePath(repositoryPath, absolutePath);

return {
content,
absolutePath,
relativePath,
};
}