import { LsCommandBuilder } from '../commands/LsCommandBuilder.js';
import { safeExec } from '../utils/exec.js';
import { pathValidator } from '../security/pathValidator.js';
import { getExtension } from '../utils/fileFilters.js';
import { getToolHints } from './hints.js';
import { applyPagination, generatePaginationHints, serializeForPagination, createPaginationInfo } from '../utils/pagination.js';
import { formatFileSize, parseFileSize } from '../utils/fileSize.js';
import { estimateTokens } from '../utils/toolHelpers.js';
import { RESOURCE_LIMITS } from '../constants.js';
import type {
ViewStructureQuery,
ViewStructureResult,
DirectoryEntry,
} from '../types.js';
import fs from 'fs';
import path from 'path';
export async function viewStructure(
query: ViewStructureQuery
): Promise<ViewStructureResult> {
try {
const pathValidation = pathValidator.validate(query.path);
if (!pathValidation.isValid) {
return {
status: 'error',
error: pathValidation.error,
researchGoal: query.researchGoal,
reasoning: query.reasoning,
hints: getToolHints('LOCAL_VIEW_STRUCTURE', 'error'),
};
}
if (query.treeView) {
return await generateTreeStructure(query, pathValidation.sanitizedPath!);
}
if (query.depth || query.recursive) {
return await viewStructureRecursive(query, pathValidation.sanitizedPath!);
}
const builder = new LsCommandBuilder();
const { command, args } = builder.fromQuery(query).build();
const result = await safeExec(command, args);
if (!result.success) {
return {
status: 'error',
error: result.stderr || 'Failed to list directory',
researchGoal: query.researchGoal,
reasoning: query.reasoning,
hints: getToolHints('LOCAL_VIEW_STRUCTURE', 'error'),
};
}
const entries = query.details
? parseLsLongFormat(result.stdout)
: await parseLsSimple(result.stdout, pathValidation.sanitizedPath!);
let filteredEntries = entries;
if (query.pattern) {
filteredEntries = filteredEntries.filter((e) =>
e.name.includes(query.pattern!)
);
}
if (query.extension) {
filteredEntries = filteredEntries.filter(
(e) => e.extension === query.extension
);
}
if (query.extensions && query.extensions.length > 0) {
filteredEntries = filteredEntries.filter(
(e) => e.extension && query.extensions!.includes(e.extension)
);
}
if (query.directoriesOnly) {
filteredEntries = filteredEntries.filter((e) => e.type === 'directory');
}
if (query.filesOnly) {
filteredEntries = filteredEntries.filter((e) => e.type === 'file');
}
const totalEntries = filteredEntries.length;
const totalFiles = filteredEntries.filter((e) => e.type === 'file').length;
const totalDirectories = filteredEntries.filter(
(e) => e.type === 'directory'
).length;
const totalSize = 0;
const entriesPerPage = query.entriesPerPage || RESOURCE_LIMITS.DEFAULT_ENTRIES_PER_PAGE;
const entryPageNumber = query.entryPageNumber || 1;
const totalPages = Math.ceil(totalEntries / entriesPerPage);
const startIdx = (entryPageNumber - 1) * entriesPerPage;
const endIdx = Math.min(startIdx + entriesPerPage, totalEntries);
let paginatedEntries = filteredEntries.slice(startIdx, endIdx);
const entryPaginationInfo = {
currentPage: entryPageNumber,
totalPages,
entriesPerPage,
totalEntries,
hasMore: entryPageNumber < totalPages,
};
if (query.limit) {
paginatedEntries = paginatedEntries.slice(0, query.limit);
}
if (!query.charLength && !query.treeView && totalEntries > RESOURCE_LIMITS.MAX_ENTRIES_BEFORE_PAGINATION && !query.entriesPerPage) {
const estimatedSize = totalEntries * (query.details ? 150 : 30);
const estimatedTokens = estimateTokens(estimatedSize);
return {
status: 'error',
error: `Found ${totalEntries} entries. Use entriesPerPage parameter for pagination (default 20).`,
path: query.path,
totalFiles,
totalDirectories,
totalSize,
researchGoal: query.researchGoal,
reasoning: query.reasoning,
hints: [
'RECOMMENDED: Use entriesPerPage=20 for paginated results (sorted by time by default)',
'ALTERNATIVE: treeView=true (shows directory structure)',
`Full result would be approximately ${estimatedTokens.toLocaleString()} tokens`,
'NOTE: Default sorting is by modification time (most recent first)'
],
};
}
let finalEntries = paginatedEntries;
let paginationMetadata: ReturnType<typeof applyPagination> | null = null;
if (query.charLength) {
const serialized = serializeForPagination(paginatedEntries, false);
paginationMetadata = applyPagination(serialized, query.charOffset ?? 0, query.charLength);
try {
finalEntries = JSON.parse(paginationMetadata.paginatedContent);
} catch {
finalEntries = paginatedEntries;
paginationMetadata = null;
}
}
const status = totalEntries > 0 ? 'hasResults' : 'empty';
const entryPaginationHints = [
`π Page ${entryPageNumber} of ${totalPages} (showing ${finalEntries.length} of ${totalEntries} entries)`,
`π Total: ${totalFiles} files, ${totalDirectories} directories`,
entryPaginationInfo.hasMore ? `βΆοΈ Next: Use entryPageNumber=${entryPageNumber + 1}` : 'β
Final page',
];
// Merge character pagination fields into main pagination when charLength is used
const pagination = {
currentPage: entryPaginationInfo.currentPage,
totalPages: entryPaginationInfo.totalPages,
entriesPerPage: entryPaginationInfo.entriesPerPage,
totalEntries: entryPaginationInfo.totalEntries,
hasMore: entryPaginationInfo.hasMore,
...(paginationMetadata && {
charOffset: paginationMetadata.charOffset,
charLength: paginationMetadata.charLength,
totalChars: paginationMetadata.totalChars,
}),
};
return {
status,
path: query.path,
entries: finalEntries,
totalFiles,
totalDirectories,
totalSize,
pagination,
...(paginationMetadata && { charPagination: createPaginationInfo(paginationMetadata) }),
researchGoal: query.researchGoal,
reasoning: query.reasoning,
hints: [...entryPaginationHints, ...getToolHints('LOCAL_VIEW_STRUCTURE', status), ...(paginationMetadata ? generatePaginationHints(paginationMetadata, { toolName: 'local_view_structure' }) : [])],
};
} catch (error) {
return {
status: 'error',
error: error instanceof Error ? error.message : String(error),
researchGoal: query.researchGoal,
reasoning: query.reasoning,
hints: getToolHints('LOCAL_VIEW_STRUCTURE', 'error'),
};
}
}
async function parseLsSimple(output: string, basePath: string): Promise<DirectoryEntry[]> {
const lines = output.split('\n').filter((line) => line.trim());
const statPromises = lines.map(async (name) => {
const fullPath = path.join(basePath, name);
try {
const stats = await fs.promises.lstat(fullPath);
return {
name,
type: stats.isDirectory() ? 'directory' as const :
stats.isSymbolicLink() ? 'symlink' as const :
'file' as const,
extension: getExtension(name),
};
} catch {
return {
name,
type: 'file' as const,
extension: getExtension(name),
};
}
});
return await Promise.all(statPromises);
}
function parseLsLongFormat(output: string): DirectoryEntry[] {
const lines = output.split('\n').filter((line) => line.trim());
const entries: DirectoryEntry[] = [];
for (const line of lines) {
if (line.startsWith('total ')) continue;
const match = line.match(
/^([\w-]+[@+]?)\s+\d+\s+\w+\s+\w+\s+([\d.]+[KMGT]?)\s+(\w+\s+\d+\s+[\d:]+)\s+(.+)$/
);
if (match) {
const [, permissions, sizeStr, modified, name] = match;
let size = 0;
if (/^\d+$/.test(sizeStr)) {
size = parseInt(sizeStr, 10);
} else {
size = parseFileSize(sizeStr);
}
let type: 'file' | 'directory' | 'symlink' = 'file';
if (permissions.startsWith('d')) type = 'directory';
else if (permissions.startsWith('l')) type = 'symlink';
entries.push({
name,
type,
size: formatFileSize(size),
modified,
permissions,
extension: getExtension(name),
});
}
}
return entries;
}
async function viewStructureRecursive(
query: ViewStructureQuery,
basePath: string
): Promise<ViewStructureResult> {
const entries: DirectoryEntry[] = [];
const maxDepth = query.depth || (query.recursive ? 5 : 2);
const maxEntries = query.limit ? query.limit * 2 : 10000;
await walkDirectory(basePath, basePath, 0, maxDepth, entries, maxEntries);
let filteredEntries = entries;
if (query.pattern) {
filteredEntries = filteredEntries.filter((e) =>
e.name.includes(query.pattern!)
);
}
if (query.extension) {
filteredEntries = filteredEntries.filter(
(e) => e.extension === query.extension
);
}
if (query.extensions && query.extensions.length > 0) {
filteredEntries = filteredEntries.filter(
(e) => e.extension && query.extensions!.includes(e.extension)
);
}
if (query.directoriesOnly) {
filteredEntries = filteredEntries.filter((e) => e.type === 'directory');
}
if (query.filesOnly) {
filteredEntries = filteredEntries.filter((e) => e.type === 'file');
}
// Apply sorting
if (query.sortBy) {
filteredEntries = filteredEntries.sort((a, b) => {
let comparison = 0;
switch (query.sortBy) {
case 'size':
comparison = (a.size || '').localeCompare(b.size || '');
break;
case 'time':
comparison = (a.modified || '').localeCompare(b.modified || '');
break;
case 'extension':
comparison = (a.extension || '').localeCompare(b.extension || '');
break;
case 'name':
default:
comparison = a.name.localeCompare(b.name);
break;
}
return query.reverse ? -comparison : comparison;
});
}
if (query.limit) {
filteredEntries = filteredEntries.slice(0, query.limit);
}
const totalFiles = filteredEntries.filter((e) => e.type === 'file').length;
const totalDirectories = filteredEntries.filter((e) => e.type === 'directory').length;
const totalSize = 0;
if (!query.charLength && filteredEntries.length > RESOURCE_LIMITS.MAX_ENTRIES_BEFORE_PAGINATION) {
const estimatedSize = filteredEntries.length * 150;
const estimatedTokens = estimateTokens(estimatedSize);
return {
status: 'error',
error: `Found ${filteredEntries.length} entries. Please use charLength parameter for large result sets.`,
path: query.path,
totalFiles,
totalDirectories,
totalSize,
researchGoal: query.researchGoal,
reasoning: query.reasoning,
hints: [
'RECOMMENDED: charLength=10000 (paginate results)',
`Full result would be approximately ${estimatedTokens.toLocaleString()} tokens`,
'ALTERNATIVE: Use limit parameter to reduce results (e.g., limit=100)',
'NOTE: Large token usage can impact performance'
],
};
}
let finalEntries = filteredEntries;
let paginationMetadata: ReturnType<typeof applyPagination> | null = null;
if (query.charLength) {
const serialized = serializeForPagination(filteredEntries, false);
paginationMetadata = applyPagination(
serialized,
query.charOffset ?? 0,
query.charLength
);
try {
finalEntries = JSON.parse(paginationMetadata.paginatedContent);
} catch {
finalEntries = filteredEntries;
paginationMetadata = null;
}
}
const status = filteredEntries.length > 0 ? 'hasResults' : 'empty';
const baseHints = getToolHints('LOCAL_VIEW_STRUCTURE', status);
const paginationHints = paginationMetadata
? generatePaginationHints(paginationMetadata, { toolName: 'local_view_structure' })
: [];
return {
status,
path: query.path,
entries: finalEntries,
totalFiles,
totalDirectories,
totalSize,
...(paginationMetadata && {
pagination: createPaginationInfo(paginationMetadata)
}),
researchGoal: query.researchGoal,
reasoning: query.reasoning,
hints: [...baseHints, ...paginationHints],
};
}
async function walkDirectory(
basePath: string,
currentPath: string,
depth: number,
maxDepth: number,
entries: DirectoryEntry[],
maxEntries: number = 10000
): Promise<void> {
if (depth >= maxDepth) return;
if (entries.length >= maxEntries) return;
try {
const items = await fs.promises.readdir(currentPath);
for (const item of items) {
const fullPath = path.join(currentPath, item);
const relativePath = path.relative(basePath, fullPath);
try {
const stats = await fs.promises.lstat(fullPath);
let type: 'file' | 'directory' | 'symlink' = 'file';
if (stats.isDirectory()) type = 'directory';
else if (stats.isSymbolicLink()) type = 'symlink';
entries.push({
name: relativePath,
type,
size: formatFileSize(stats.size),
modified: stats.mtime.toISOString(),
extension: getExtension(item),
});
if (type === 'directory') {
await walkDirectory(basePath, fullPath, depth + 1, maxDepth, entries, maxEntries);
}
} catch {
// Skip inaccessible items
}
}
} catch {
// Skip unreadable directories
}
}
async function generateTreeStructure(
query: ViewStructureQuery,
basePath: string
): Promise<ViewStructureResult> {
const maxDepth = query.depth || 3;
const treeLines: string[] = [];
const rootName = path.basename(basePath) || basePath;
treeLines.push(rootName);
treeLines.push('β');
await buildTree(basePath, basePath, 0, maxDepth, treeLines, '', query);
const treeStructure = treeLines.join('\n');
let finalStructure = treeStructure;
let paginationMetadata: ReturnType<typeof applyPagination> | null = null;
if (query.charLength) {
paginationMetadata = applyPagination(
treeStructure,
query.charOffset ?? 0,
query.charLength
);
finalStructure = paginationMetadata.paginatedContent;
}
const status = treeStructure.length > 0 ? 'hasResults' : 'empty';
const baseHints = getToolHints('LOCAL_VIEW_STRUCTURE', status);
const paginationHints = paginationMetadata
? generatePaginationHints(paginationMetadata, { toolName: 'local_view_structure' })
: [];
const treeHints = [
'Tree view shows directory structure with visual hierarchy',
'Directories shown without sizes, files shown with sizes',
'Use depth parameter to control tree depth (default: 3)',
'For metadata view, set treeView=false and details=true'
];
return {
status,
path: query.path,
treeStructure: finalStructure,
...(paginationMetadata && {
pagination: createPaginationInfo(paginationMetadata)
}),
researchGoal: query.researchGoal,
reasoning: query.reasoning,
hints: [...baseHints, ...paginationHints, ...treeHints],
};
}
async function buildTree(
basePath: string,
currentPath: string,
depth: number,
maxDepth: number,
treeLines: string[],
prefix: string,
query: ViewStructureQuery
): Promise<void> {
if (depth >= maxDepth) return;
try {
const items = await fs.promises.readdir(currentPath);
let filteredItems = items;
if (query.hidden === false) {
filteredItems = filteredItems.filter(item => !item.startsWith('.'));
}
if (query.pattern) {
filteredItems = filteredItems.filter(item => item.includes(query.pattern!));
}
if (query.extension) {
filteredItems = filteredItems.filter(item => getExtension(item) === query.extension);
}
if (query.extensions && query.extensions.length > 0) {
filteredItems = filteredItems.filter(item =>
getExtension(item) && query.extensions!.includes(getExtension(item)!)
);
}
if (query.filesOnly) {
const fileItems: string[] = [];
for (const item of filteredItems) {
try {
const stats = await fs.promises.lstat(path.join(currentPath, item));
if (stats.isFile()) fileItems.push(item);
} catch {
// Skip inaccessible items
}
}
filteredItems = fileItems;
}
if (query.directoriesOnly) {
const dirItems: string[] = [];
for (const item of filteredItems) {
try {
const stats = await fs.promises.lstat(path.join(currentPath, item));
if (stats.isDirectory()) dirItems.push(item);
} catch {
// Skip inaccessible items
}
}
filteredItems = dirItems;
}
filteredItems.sort();
if (query.limit) {
filteredItems = filteredItems.slice(0, query.limit);
}
for (let i = 0; i < filteredItems.length; i++) {
const item = filteredItems[i];
const fullPath = path.join(currentPath, item);
const isLast = i === filteredItems.length - 1;
const connector = isLast ? 'βββ ' : 'βββ ';
const nextPrefix = prefix + (isLast ? ' ' : 'β ');
try {
const stats = await fs.promises.lstat(fullPath);
const isDirectory = stats.isDirectory();
let itemDisplay = item;
const sizeStr = (query.humanReadable ?? true) ? formatFileSize(stats.size) : stats.size.toString();
if (isDirectory) {
itemDisplay = `${item}`;
} else {
if (query.details) {
const dateStr = stats.mtime.toISOString().split('T')[0];
itemDisplay = `${item} ${sizeStr} (${dateStr})`;
} else {
itemDisplay = `${item} ${sizeStr}`;
}
}
treeLines.push(`${prefix}${connector}${itemDisplay}`);
if (isDirectory && (query.recursive || depth < maxDepth - 1)) {
await buildTree(basePath, fullPath, depth + 1, maxDepth, treeLines, nextPrefix, query);
}
} catch {
treeLines.push(`${prefix}${connector}[INACCESSIBLE] ${item}`);
}
}
} catch (error) {
treeLines.push(`${prefix}[ERROR] Error reading directory: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}