import fs from 'fs/promises';
import path from 'path';
import { minimatch } from 'minimatch';
import { SearchByTimeInput } from './schema.js';
import { CONFIG, getDefaultRoot } from '../../core/config.js';
import { validatePath } from '../../core/pathPolicy.js';
import { getFileTimes } from '../../core/fileTimes.js';
import { parseTime, isTimeInRange } from '../../core/timeRange.js';
import { encodeCursor, decodeCursor } from '../../core/cursor.js';
import { ToolError, ERROR_CODES } from '../../core/errors.js';
interface FileEntry {
path: string; // relative to root
isDirectory: boolean;
modified: number;
created: number;
size: number;
}
export async function handleSearchByTime(args: SearchByTimeInput) {
const root = args.root || getDefaultRoot();
const startPath = args.path || "";
const absoluteStartPath = validatePath(root, startPath);
const fromTime = parseTime(args.from);
const toTime = parseTime(args.to);
if (fromTime !== undefined && toTime !== undefined && fromTime > toTime) {
throw new ToolError(ERROR_CODES.InvalidRange, "'from' must not be later than 'to'.", "Set 'from' <= 'to'.");
}
const limit = Math.min(args.limit, CONFIG.maxLimit);
const maxDepth = args.maxDepth ?? CONFIG.maxDepth;
const cursorData = args.cursor ? decodeCursor(args.cursor) : null;
const results: FileEntry[] = [];
let filesScanned = 0;
let dirsScanned = 0;
async function scan(currentPath: string, depth: number) {
if (filesScanned >= CONFIG.maxFilesScanned || dirsScanned >= CONFIG.maxDirectoriesScanned) {
return;
}
if (depth > maxDepth) return;
let entries;
try {
entries = await fs.readdir(currentPath, { withFileTypes: true });
} catch (e) {
return;
}
dirsScanned++;
for (const entry of entries) {
if (filesScanned >= CONFIG.maxFilesScanned) break;
const fullPath = path.join(currentPath, entry.name);
const relativePath = path.relative(path.resolve(root), fullPath).replace(/\\/g, '/');
let stats;
try {
stats = await fs.stat(fullPath);
} catch (e) {
continue;
}
const times = getFileTimes(stats);
const timeValue = args.timeField === 'modified' ? times.modified : times.created;
const isDir = entry.isDirectory();
let matches = false;
if (isDir) {
if (args.includeDirectories) matches = true;
} else {
if (args.includeFiles) matches = true;
}
if (matches) {
if (isTimeInRange(timeValue, fromTime, toTime)) {
if (!args.glob || minimatch(relativePath, args.glob, { dot: true })) {
results.push({
path: relativePath,
isDirectory: isDir,
modified: times.modified,
created: times.created,
size: stats.size
});
}
}
}
filesScanned++;
if (isDir && args.recursive) {
await scan(fullPath, depth + 1);
}
}
}
await scan(absoluteStartPath, 0);
results.sort((a, b) => {
const timeA = args.timeField === 'modified' ? a.modified : a.created;
const timeB = args.timeField === 'modified' ? b.modified : b.created;
if (args.sort === 'time_desc') {
if (timeB !== timeA) return timeB - timeA;
return a.path.localeCompare(b.path);
} else if (args.sort === 'time_asc') {
if (timeA !== timeB) return timeA - timeB;
return a.path.localeCompare(b.path);
} else { // path_asc
return a.path.localeCompare(b.path);
}
});
let finalResults = results;
if (cursorData) {
const cursorTime = cursorData.lastTime;
const cursorPath = cursorData.lastPath;
finalResults = results.filter(item => {
const itemTime = args.timeField === 'modified' ? item.modified : item.created;
if (args.sort === 'time_desc') {
if (itemTime < cursorTime) return true;
if (itemTime === cursorTime && item.path > cursorPath) return true;
return false;
} else if (args.sort === 'time_asc') {
if (itemTime > cursorTime) return true;
if (itemTime === cursorTime && item.path > cursorPath) return true;
return false;
} else {
return item.path > cursorPath;
}
});
}
const sliced = finalResults.slice(0, limit);
let nextCursor = undefined;
if (finalResults.length > limit) {
const lastItem = sliced[sliced.length - 1];
nextCursor = encodeCursor({
lastTime: args.timeField === 'modified' ? lastItem.modified : lastItem.created,
lastPath: lastItem.path
});
}
return {
results: sliced.map(r => ({
...r,
modified: new Date(r.modified).toISOString(),
created: new Date(r.created).toISOString()
})),
nextCursor
};
}