/**
* summary and tree commands
*
* - summary: Get project summary with auto-detected info
* - tree: Get indexed file tree with optional stats
*/
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { join, dirname, basename } from 'path';
import { PRODUCT_NAME, INDEX_DIR, TOOL_PREFIX } from '../constants.js';
import { openDatabase, createQueries, type AiDexDatabase } from '../db/index.js';
// ============================================================
// Types - Summary
// ============================================================
export interface SummaryParams {
path: string; // Project path
}
export interface SummaryResult {
success: boolean;
name: string;
content: string; // Markdown content of summary.md (if exists)
autoGenerated: {
entryPoints: string[];
mainTypes: string[];
fileCount: number;
languages: string[];
};
error?: string;
}
// ============================================================
// Types - Tree
// ============================================================
export interface TreeParams {
path: string; // Project path
subpath?: string; // Subdirectory to list (default: root)
depth?: number; // Max depth (default: unlimited)
includeStats?: boolean; // Include item/method counts per file
}
export interface TreeEntry {
path: string;
type: 'file' | 'directory';
itemCount?: number;
methodCount?: number;
typeCount?: number;
}
export interface TreeResult {
success: boolean;
root: string;
entries: TreeEntry[];
totalFiles: number;
error?: string;
}
// ============================================================
// Summary implementation
// ============================================================
export function summary(params: SummaryParams): SummaryResult {
const { path: projectPath } = params;
// Validate project path
const indexDir = join(projectPath, INDEX_DIR);
const dbPath = join(indexDir, 'index.db');
if (!existsSync(dbPath)) {
return {
success: false,
name: '',
content: '',
autoGenerated: {
entryPoints: [],
mainTypes: [],
fileCount: 0,
languages: [],
},
error: `No ${PRODUCT_NAME} index found at ${projectPath}. Run ${TOOL_PREFIX}init first.`,
};
}
// Open database
const db = openDatabase(dbPath, true);
const queries = createQueries(db);
try {
const projectName = db.getMetadata('project_name') ?? basename(projectPath);
// Read summary.md if exists
const summaryPath = join(indexDir, 'summary.md');
let content = '';
if (existsSync(summaryPath)) {
content = readFileSync(summaryPath, 'utf-8');
}
// Auto-detect entry points
const entryPoints = detectEntryPoints(queries);
// Get main types (most referenced)
const mainTypes = getMainTypes(queries, db);
// Get file count
const stats = db.getStats();
// Detect languages from file extensions
const languages = detectLanguages(queries);
db.close();
return {
success: true,
name: projectName,
content,
autoGenerated: {
entryPoints,
mainTypes,
fileCount: stats.files,
languages,
},
};
} catch (err) {
db.close();
return {
success: false,
name: '',
content: '',
autoGenerated: {
entryPoints: [],
mainTypes: [],
fileCount: 0,
languages: [],
},
error: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Detect entry points based on common patterns
*/
function detectEntryPoints(queries: ReturnType<typeof createQueries>): string[] {
const files = queries.getAllFiles();
const entryPatterns = [
/^(program|main|index|app|application)\.(cs|ts|js|py|rs)$/i,
/^src\/(program|main|index|app)\.(cs|ts|js|py|rs)$/i,
/^src\/(main|lib)\.(rs)$/i,
];
const entryPoints: string[] = [];
for (const file of files) {
const fileName = file.path.replace(/\\/g, '/');
for (const pattern of entryPatterns) {
if (pattern.test(fileName) || pattern.test(basename(fileName))) {
entryPoints.push(file.path);
break;
}
}
}
return entryPoints;
}
/**
* Get main types (classes/interfaces with most methods)
* Uses bulk SQL queries instead of N+1 per-file queries.
* Methods are counted between a type's line_number and the next type's line_number in the same file.
*/
function getMainTypes(queries: ReturnType<typeof createQueries>, db: AiDexDatabase): string[] {
const rawDb = db.getDb();
// Load ALL types and methods in two bulk queries
const allTypes = rawDb.prepare(
'SELECT t.file_id, t.name, t.line_number, f.path FROM types t JOIN files f ON t.file_id = f.id ORDER BY t.file_id, t.line_number'
).all() as Array<{ file_id: number; name: string; line_number: number; path: string }>;
const allMethods = rawDb.prepare(
'SELECT file_id, line_number FROM methods ORDER BY file_id, line_number'
).all() as Array<{ file_id: number; line_number: number }>;
// Group methods by file_id for fast lookup
const methodsByFile = new Map<number, number[]>();
for (const m of allMethods) {
let arr = methodsByFile.get(m.file_id);
if (!arr) {
arr = [];
methodsByFile.set(m.file_id, arr);
}
arr.push(m.line_number);
}
// Count methods per type: only count between this type's start and the next type's start
const typeMethodCounts: Array<{ name: string; file: string; methodCount: number }> = [];
for (let i = 0; i < allTypes.length; i++) {
const type = allTypes[i];
const nextType = (i + 1 < allTypes.length && allTypes[i + 1].file_id === type.file_id)
? allTypes[i + 1]
: null;
const fileMethods = methodsByFile.get(type.file_id);
if (!fileMethods) {
typeMethodCounts.push({ name: type.name, file: type.path, methodCount: 0 });
continue;
}
const lowerBound = type.line_number;
const upperBound = nextType ? nextType.line_number : Infinity;
const methodCount = fileMethods.filter(ln => ln > lowerBound && ln < upperBound).length;
typeMethodCounts.push({
name: type.name,
file: type.path,
methodCount,
});
}
// Sort by method count and return top 5
typeMethodCounts.sort((a, b) => b.methodCount - a.methodCount);
return typeMethodCounts.slice(0, 5).map(t => `${t.name} (${t.file})`);
}
/**
* Detect languages from indexed file extensions
*/
function detectLanguages(queries: ReturnType<typeof createQueries>): string[] {
const files = queries.getAllFiles();
const extensionMap: Record<string, string> = {
'.cs': 'C#',
'.ts': 'TypeScript',
'.tsx': 'TypeScript',
'.js': 'JavaScript',
'.jsx': 'JavaScript',
'.mjs': 'JavaScript',
'.cjs': 'JavaScript',
'.rs': 'Rust',
'.py': 'Python',
'.pyw': 'Python',
'.c': 'C',
'.h': 'C/C++',
'.cpp': 'C++',
'.cc': 'C++',
'.cxx': 'C++',
'.hpp': 'C++',
'.hxx': 'C++',
'.java': 'Java',
'.go': 'Go',
'.php': 'PHP',
'.rb': 'Ruby',
};
const languages = new Set<string>();
for (const file of files) {
const ext = file.path.substring(file.path.lastIndexOf('.')).toLowerCase();
if (extensionMap[ext]) {
languages.add(extensionMap[ext]);
}
}
return [...languages].sort();
}
// ============================================================
// Tree implementation
// ============================================================
export function tree(params: TreeParams): TreeResult {
const { path: projectPath, subpath, depth, includeStats } = params;
// Validate project path
const indexDir = join(projectPath, INDEX_DIR);
const dbPath = join(indexDir, 'index.db');
if (!existsSync(dbPath)) {
return {
success: false,
root: '',
entries: [],
totalFiles: 0,
error: `No ${PRODUCT_NAME} index found at ${projectPath}. Run ${TOOL_PREFIX}init first.`,
};
}
// Open database
const db = openDatabase(dbPath, true);
const queries = createQueries(db);
try {
const files = queries.getAllFiles();
const normalizedSubpath = subpath?.replace(/\\/g, '/').replace(/^\/|\/$/g, '') ?? '';
// Filter files by subpath
let filteredFiles = files;
if (normalizedSubpath) {
filteredFiles = files.filter(f => {
const normalizedPath = f.path.replace(/\\/g, '/');
return normalizedPath.startsWith(normalizedSubpath + '/') ||
normalizedPath === normalizedSubpath;
});
}
// Pre-load stats in bulk if needed (avoids N+1 queries)
let statsMap: Map<number, { itemCount: number; methodCount: number; typeCount: number }> | null = null;
if (includeStats) {
const rawDb = db.getDb();
statsMap = new Map();
// Bulk load unique item counts per file
const itemCounts = rawDb.prepare(
'SELECT file_id, COUNT(DISTINCT item_id) as cnt FROM occurrences GROUP BY file_id'
).all() as Array<{ file_id: number; cnt: number }>;
for (const row of itemCounts) {
statsMap.set(row.file_id, { itemCount: row.cnt, methodCount: 0, typeCount: 0 });
}
// Bulk load method counts per file
const methodCounts = rawDb.prepare(
'SELECT file_id, COUNT(*) as cnt FROM methods GROUP BY file_id'
).all() as Array<{ file_id: number; cnt: number }>;
for (const row of methodCounts) {
const entry = statsMap.get(row.file_id);
if (entry) {
entry.methodCount = row.cnt;
} else {
statsMap.set(row.file_id, { itemCount: 0, methodCount: row.cnt, typeCount: 0 });
}
}
// Bulk load type counts per file
const typeCounts = rawDb.prepare(
'SELECT file_id, COUNT(*) as cnt FROM types GROUP BY file_id'
).all() as Array<{ file_id: number; cnt: number }>;
for (const row of typeCounts) {
const entry = statsMap.get(row.file_id);
if (entry) {
entry.typeCount = row.cnt;
} else {
statsMap.set(row.file_id, { itemCount: 0, methodCount: 0, typeCount: row.cnt });
}
}
}
// Build tree structure
const directories = new Set<string>();
const entries: TreeEntry[] = [];
for (const file of filteredFiles) {
const normalizedPath = file.path.replace(/\\/g, '/');
const relativePath = normalizedSubpath
? normalizedPath.substring(normalizedSubpath.length + 1)
: normalizedPath;
// Check depth
const pathDepth = relativePath.split('/').length;
if (depth !== undefined && pathDepth > depth) {
// Just add parent directories up to depth
const parts = relativePath.split('/');
for (let i = 0; i < Math.min(depth, parts.length - 1); i++) {
const dirPath = parts.slice(0, i + 1).join('/');
directories.add(dirPath);
}
continue;
}
// Add parent directories
const parts = relativePath.split('/');
for (let i = 0; i < parts.length - 1; i++) {
const dirPath = parts.slice(0, i + 1).join('/');
directories.add(dirPath);
}
// Add file entry
const entry: TreeEntry = {
path: relativePath,
type: 'file',
};
if (statsMap) {
const fileStats = statsMap.get(file.id);
entry.itemCount = fileStats?.itemCount ?? 0;
entry.methodCount = fileStats?.methodCount ?? 0;
entry.typeCount = fileStats?.typeCount ?? 0;
}
entries.push(entry);
}
// Add directory entries
for (const dir of directories) {
entries.push({
path: dir,
type: 'directory',
});
}
// Sort: directories first, then alphabetically
entries.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1;
}
return a.path.localeCompare(b.path);
});
db.close();
return {
success: true,
root: normalizedSubpath || '.',
entries,
totalFiles: filteredFiles.length,
};
} catch (err) {
db.close();
return {
success: false,
root: '',
entries: [],
totalFiles: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}
// ============================================================
// Describe - Add content to summary.md
// ============================================================
export interface DescribeParams {
path: string;
section: 'purpose' | 'architecture' | 'concepts' | 'patterns' | 'notes';
content: string;
replace?: boolean; // Replace existing section? Default: append
}
export interface DescribeResult {
success: boolean;
section: string;
error?: string;
}
export function describe(params: DescribeParams): DescribeResult {
const { path: projectPath, section, content, replace = false } = params;
// Validate project path
const indexDir = join(projectPath, INDEX_DIR);
const dbPath = join(indexDir, 'index.db');
if (!existsSync(dbPath)) {
return {
success: false,
section,
error: `No ${PRODUCT_NAME} index found at ${projectPath}. Run ${TOOL_PREFIX}init first.`,
};
}
const summaryPath = join(indexDir, 'summary.md');
try {
// Read existing summary or create new
let summaryContent = '';
if (existsSync(summaryPath)) {
summaryContent = readFileSync(summaryPath, 'utf-8');
}
// Section headers
const sectionHeaders: Record<string, string> = {
purpose: '## Purpose',
architecture: '## Architecture',
concepts: '## Key Concepts',
patterns: '## Patterns',
notes: '## Notes',
};
const header = sectionHeaders[section];
const sectionRegex = new RegExp(`^${header}\\n([\\s\\S]*?)(?=^## |$)`, 'm');
if (replace) {
// Replace entire section
if (sectionRegex.test(summaryContent)) {
summaryContent = summaryContent.replace(sectionRegex, `${header}\n${content}\n\n`);
} else {
// Add new section at end
summaryContent = summaryContent.trimEnd() + `\n\n${header}\n${content}\n`;
}
} else {
// Append to section
const match = summaryContent.match(sectionRegex);
if (match) {
const existingContent = match[1].trimEnd();
const newContent = existingContent ? `${existingContent}\n${content}` : content;
summaryContent = summaryContent.replace(sectionRegex, `${header}\n${newContent}\n\n`);
} else {
// Add new section at end
summaryContent = summaryContent.trimEnd() + `\n\n${header}\n${content}\n`;
}
}
// Write back
writeFileSync(summaryPath, summaryContent.trimStart());
return {
success: true,
section,
};
} catch (err) {
return {
success: false,
section,
error: err instanceof Error ? err.message : String(err),
};
}
}