import { readFile, stat } from 'fs/promises';
import { resolve, isAbsolute } from 'path';
import { minifyContent } from '../utils/minifier.js';
import { getToolHints } from './hints.js';
import { applyPagination, generatePaginationHints, createPaginationInfo } from '../utils/pagination.js';
import { RESOURCE_LIMITS, DEFAULTS } from '../constants.js';
import {
validateToolPath,
createErrorResult,
estimateTokens,
} from '../utils/toolHelpers.js';
import type { FetchContentQuery, FetchContentResult } from '../types.js';
export async function fetchContent(
query: FetchContentQuery
): Promise<FetchContentResult> {
try {
const pathValidation = validateToolPath(query, 'LOCAL_FETCH_CONTENT');
if (!pathValidation.isValid) {
return pathValidation.errorResult as FetchContentResult;
}
const absolutePath = isAbsolute(query.path)
? query.path
: resolve(process.cwd(), query.path);
let fileStats;
try {
fileStats = await stat(absolutePath);
} catch (error) {
return createErrorResult(
new Error(`Failed to access file: ${error instanceof Error ? error.message : String(error)}`),
'LOCAL_FETCH_CONTENT',
query,
{ path: query.path }
) as FetchContentResult;
}
const fileSizeKB = fileStats.size / 1024;
if (
fileSizeKB > RESOURCE_LIMITS.LARGE_FILE_THRESHOLD_KB &&
!query.charLength &&
!query.matchString
) {
return createErrorResult(
new Error(`File is ${Math.round(fileSizeKB)}KB. Please use one of these options for large files:`),
'LOCAL_FETCH_CONTENT',
query,
{
path: query.path,
hints: [
`RECOMMENDED: charLength=${RESOURCE_LIMITS.RECOMMENDED_CHAR_LENGTH} (paginate through file)`,
'EFFICIENT: matchString="pattern" (extract only matching sections)',
'',
`Full file would be approximately ${Math.round(fileSizeKB * 0.25)}K tokens`,
`NOTE: fullContent=true REQUIRES charLength for files >${RESOURCE_LIMITS.LARGE_FILE_THRESHOLD_KB}KB`,
'NOTE: Large token usage can impact performance'
],
}
) as FetchContentResult;
}
let content: string;
try {
content = await readFile(absolutePath, 'utf-8');
} catch (error) {
return createErrorResult(
new Error(`Failed to read file: ${error instanceof Error ? error.message : String(error)}`),
'LOCAL_FETCH_CONTENT',
query,
{ path: query.path }
) as FetchContentResult;
}
const lines = content.split('\n');
const totalLines = lines.length;
let resultContent: string;
let isPartial = false;
if (query.matchString) {
const result = extractMatchingLines(
lines,
query.matchString,
query.matchStringContextLines ?? 5
);
if (result.lines.length === 0) {
return {
status: 'empty',
path: query.path,
error: `No matches found for pattern: ${query.matchString}`,
totalLines,
researchGoal: query.researchGoal,
reasoning: query.reasoning,
hints: getToolHints('LOCAL_FETCH_CONTENT', 'empty'),
};
}
resultContent = result.lines.join('\n');
if (!query.charLength && resultContent.length > DEFAULTS.MAX_OUTPUT_CHARS) {
const estimatedTokens = estimateTokens(resultContent.length);
return {
status: 'error',
error: `Match result is ${resultContent.length.toLocaleString()} chars (~${estimatedTokens.toLocaleString()} tokens). Please use charLength parameter.`,
path: query.path,
totalLines,
researchGoal: query.researchGoal,
reasoning: query.reasoning,
hints: [
`RECOMMENDED: charLength=${RESOURCE_LIMITS.RECOMMENDED_CHAR_LENGTH} (paginate results)`,
`Full result would be approximately ${estimatedTokens.toLocaleString()} tokens`,
'NOTE: Files with long lines (e.g., minified) may need pagination for pattern matching',
],
};
}
isPartial = true;
} else {
resultContent = content;
isPartial = false;
}
let wasMinified = false;
if (query.minified !== false) {
try{
const originalLength = resultContent.length;
const minifiedContent = minifyContent(resultContent, query.path);
if (minifiedContent.length < originalLength) {
resultContent = minifiedContent;
wasMinified = true;
}
} catch {
// Keep original if minification fails
}
}
if (!resultContent || resultContent.trim().length === 0) {
return {
status: 'empty',
path: query.path,
totalLines,
researchGoal: query.researchGoal,
reasoning: query.reasoning,
hints: getToolHints('LOCAL_FETCH_CONTENT', 'empty'),
};
}
if (!query.charLength && resultContent.length > DEFAULTS.MAX_OUTPUT_CHARS) {
const estimatedTokens = estimateTokens(resultContent.length);
return {
status: 'error',
error: `Result is ${resultContent.length.toLocaleString()} chars (~${estimatedTokens.toLocaleString()} tokens). Please use charLength parameter.`,
path: query.path,
totalLines,
researchGoal: query.researchGoal,
reasoning: query.reasoning,
hints: [
`RECOMMENDED: charLength=${RESOURCE_LIMITS.RECOMMENDED_CHAR_LENGTH} (paginate results)`,
`Full result would be approximately ${estimatedTokens.toLocaleString()} tokens`,
'ALTERNATIVE: Use matchString for targeted extraction',
`NOTE: Results >10K chars require pagination for safety`,
],
};
}
const pagination = applyPagination(
resultContent,
query.charOffset ?? 0,
query.charLength
);
const baseHints = getToolHints('LOCAL_FETCH_CONTENT', 'hasResults');
const paginationHints = query.charLength
? generatePaginationHints(pagination, { toolName: 'local_fetch_content' })
: [];
return {
status: 'hasResults',
path: query.path,
content: pagination.paginatedContent,
contentLength: pagination.paginatedContent.length,
estimatedTokens: pagination.estimatedTokens,
isPartial,
totalLines,
minified: query.minified !== false ? wasMinified : undefined,
...(query.charLength && {
pagination: createPaginationInfo(pagination)
}),
researchGoal: query.researchGoal,
reasoning: query.reasoning,
hints: [...baseHints, ...paginationHints],
};
} catch (error) {
return {
status: 'error',
error: error instanceof Error ? error.message : String(error),
path: query.path,
researchGoal: query.researchGoal,
reasoning: query.reasoning,
hints: getToolHints('LOCAL_FETCH_CONTENT', 'error'),
};
}
}
function extractMatchingLines(
lines: string[],
pattern: string,
contextLines: number
): { lines: string[]; startLine: number; endLine: number } {
const matchingLineNumbers: number[] = [];
lines.forEach((line, index) => {
if (line.includes(pattern)) {
matchingLineNumbers.push(index + 1);
}
});
if (matchingLineNumbers.length === 0) {
return { lines: [], startLine: 0, endLine: 0 };
}
const firstMatch = matchingLineNumbers[0];
const lastMatch = matchingLineNumbers[matchingLineNumbers.length - 1];
const startLine = Math.max(1, firstMatch - contextLines);
const endLine = Math.min(lines.length, lastMatch + contextLines);
const extractedLines = lines.slice(startLine - 1, endLine);
return {
lines: extractedLines,
startLine,
endLine,
};
}