11import { beforeEach , describe , expect , it , vi } from "vitest" ;
2- import type { Dirent } from "node:fs" ;
32
4- import { listMarkdownFiles , readDirectory , statVirtualFile } from "./fs-enumeration.js" ;
5- import { mockFsFunction } from "./test-helpers.js" ;
3+ import { listFolders , listMarkdownFiles , readDirectory , statVirtualFile } from "./fs-enumeration.js" ;
4+ import { makeDirent , mockFsFunction } from "./test-helpers.js" ;
65
76vi . mock ( "node:fs/promises" , ( ) => ( {
87 readdir : vi . fn ( ) ,
@@ -17,21 +16,6 @@ const realpathMock = mockFsFunction(realpath);
1716
1817const OPTIONS = { vaultRoot : "/vault" , allowed : [ ] as string [ ] , blocked : [ ] as string [ ] } ;
1918
20- function makeDirent ( name : string , isDir : boolean ) : Dirent {
21- return {
22- name,
23- isDirectory : ( ) => isDir ,
24- isFile : ( ) => ! isDir ,
25- isBlockDevice : ( ) => false ,
26- isCharacterDevice : ( ) => false ,
27- isFIFO : ( ) => false ,
28- isSocket : ( ) => false ,
29- isSymbolicLink : ( ) => false ,
30- parentPath : "/vault" ,
31- path : "/vault" ,
32- } ;
33- }
34-
3519describe ( "readDirectory" , ( ) => {
3620 beforeEach ( ( ) => {
3721 vi . resetAllMocks ( ) ;
@@ -142,20 +126,29 @@ describe("listMarkdownFiles", () => {
142126 } ) ;
143127
144128 it ( "enumerates markdown files sorted alphabetically" , async ( ) => {
145- readdirMock . mockResolvedValueOnce ( [ "b-note.md" , "a-note.md" , "image.png" ] ) ;
129+ readdirMock . mockResolvedValueOnce ( [
130+ makeDirent ( "b-note.md" , false ) ,
131+ makeDirent ( "a-note.md" , false ) ,
132+ makeDirent ( "image.png" , false ) ,
133+ ] ) ;
146134 const result = await listMarkdownFiles ( OPTIONS ) ;
147135 expect ( result ) . toEqual ( { ok : true , value : [ "a-note.md" , "b-note.md" ] } ) ;
148136 } ) ;
149137
150138 it ( "skips entries with dot-prefixed path segments" , async ( ) => {
151- readdirMock . mockResolvedValueOnce ( [ ".obsidian/plugins/note.md" , "visible.md" ] ) ;
139+ readdirMock . mockResolvedValueOnce ( [
140+ makeDirent ( ".obsidian" , true ) ,
141+ makeDirent ( "visible.md" , false ) ,
142+ ] ) ;
152143 const result = await listMarkdownFiles ( OPTIONS ) ;
153144 expect ( result ) . toEqual ( { ok : true , value : [ "visible.md" ] } ) ;
154145 } ) ;
155146
156147 it ( "searches only allowed when specified" , async ( ) => {
157148 const options = { ...OPTIONS , allowed : [ "notes" , "docs" ] } ;
158- readdirMock . mockResolvedValueOnce ( [ "intro.md" ] ) . mockResolvedValueOnce ( [ "guide.md" ] ) ;
149+ readdirMock
150+ . mockResolvedValueOnce ( [ makeDirent ( "intro.md" , false ) ] )
151+ . mockResolvedValueOnce ( [ makeDirent ( "guide.md" , false ) ] ) ;
159152 const result = await listMarkdownFiles ( options ) ;
160153 expect ( result ) . toEqual ( {
161154 ok : true ,
@@ -171,30 +164,167 @@ describe("listMarkdownFiles", () => {
171164
172165 it ( "skips unreadable directories" , async ( ) => {
173166 const options = { ...OPTIONS , allowed : [ "bad" , "good" ] } ;
174- readdirMock . mockRejectedValueOnce ( new Error ( "ENOENT" ) ) . mockResolvedValueOnce ( [ "note.md" ] ) ;
167+ readdirMock
168+ . mockRejectedValueOnce ( new Error ( "ENOENT" ) )
169+ . mockResolvedValueOnce ( [ makeDirent ( "note.md" , false ) ] ) ;
175170 const result = await listMarkdownFiles ( options ) ;
176171 expect ( result ) . toEqual ( { ok : true , value : [ "good/note.md" ] } ) ;
177172 } ) ;
178173
179174 it ( "handles nested subdirectories" , async ( ) => {
180- readdirMock . mockResolvedValueOnce ( [ "sub/deep/note.md" , "top.md" ] ) ;
175+ readdirMock
176+ . mockResolvedValueOnce ( [ makeDirent ( "sub" , true ) , makeDirent ( "top.md" , false ) ] )
177+ . mockResolvedValueOnce ( [ makeDirent ( "deep" , true ) ] )
178+ . mockResolvedValueOnce ( [ makeDirent ( "note.md" , false ) ] ) ;
181179 const result = await listMarkdownFiles ( OPTIONS ) ;
182180 expect ( result ) . toEqual ( { ok : true , value : [ "sub/deep/note.md" , "top.md" ] } ) ;
183181 } ) ;
184182
185183 it ( "excludes files in blocked folders" , async ( ) => {
186184 const options = { ...OPTIONS , allowed : [ "notes" ] , blocked : [ "notes/draft" ] } ;
187- readdirMock . mockResolvedValueOnce ( [ "public/doc.md" , "draft/wip.md" ] ) ;
185+ readdirMock
186+ . mockResolvedValueOnce ( [ makeDirent ( "public" , true ) , makeDirent ( "draft" , true ) ] )
187+ . mockResolvedValueOnce ( [ makeDirent ( "doc.md" , false ) ] ) ;
188188 const result = await listMarkdownFiles ( options ) ;
189189 expect ( result ) . toEqual ( { ok : true , value : [ "notes/public/doc.md" ] } ) ;
190190 } ) ;
191191
192192 it ( "excludes blocked files without allowed" , async ( ) => {
193193 const options = { ...OPTIONS , blocked : [ "private" ] } ;
194- readdirMock . mockResolvedValueOnce ( [ "notes/doc.md" , "private/secret.md" ] ) ;
194+ readdirMock
195+ . mockResolvedValueOnce ( [ makeDirent ( "notes" , true ) , makeDirent ( "private" , true ) ] )
196+ . mockResolvedValueOnce ( [ makeDirent ( "doc.md" , false ) ] ) ;
195197 const result = await listMarkdownFiles ( options ) ;
196198 expect ( result ) . toEqual ( { ok : true , value : [ "notes/doc.md" ] } ) ;
197199 } ) ;
200+
201+ it ( "respects depthLimit parameter" , async ( ) => {
202+ readdirMock
203+ . mockResolvedValueOnce ( [ makeDirent ( "sub" , true ) , makeDirent ( "top.md" , false ) ] )
204+ . mockResolvedValueOnce ( [ makeDirent ( "deep" , true ) , makeDirent ( "mid.md" , false ) ] )
205+ . mockResolvedValueOnce ( [ makeDirent ( "bottom.md" , false ) ] ) ;
206+ const result = await listMarkdownFiles ( OPTIONS , 2 ) ;
207+ expect ( result ) . toEqual ( { ok : true , value : [ "sub/mid.md" , "top.md" ] } ) ;
208+ } ) ;
209+
210+ it ( "treats depthLimit 0 as unlimited" , async ( ) => {
211+ readdirMock
212+ . mockResolvedValueOnce ( [ makeDirent ( "sub" , true ) ] )
213+ . mockResolvedValueOnce ( [ makeDirent ( "deep" , true ) ] )
214+ . mockResolvedValueOnce ( [ makeDirent ( "note.md" , false ) ] ) ;
215+ const result = await listMarkdownFiles ( OPTIONS , 0 ) ;
216+ expect ( result ) . toEqual ( { ok : true , value : [ "sub/deep/note.md" ] } ) ;
217+ } ) ;
218+ } ) ;
219+
220+ describe ( "listFolders" , ( ) => {
221+ beforeEach ( ( ) => {
222+ vi . resetAllMocks ( ) ;
223+ } ) ;
224+
225+ it ( "enumerates folders recursively up to depthLimit" , async ( ) => {
226+ readdirMock
227+ . mockResolvedValueOnce ( [
228+ makeDirent ( "10-projects" , true ) ,
229+ makeDirent ( "20-areas" , true ) ,
230+ makeDirent ( "note.md" , false ) ,
231+ ] )
232+ . mockResolvedValueOnce ( [ makeDirent ( "active" , true ) , makeDirent ( "archive" , true ) ] )
233+ . mockResolvedValueOnce ( [ ] ) ;
234+ const result = await listFolders ( OPTIONS , 2 ) ;
235+ expect ( result ) . toEqual ( {
236+ ok : true ,
237+ value : [ "10-projects" , "10-projects/active" , "10-projects/archive" , "20-areas" ] ,
238+ } ) ;
239+ } ) ;
240+
241+ it ( "depth 1 returns only top-level folders" , async ( ) => {
242+ readdirMock . mockResolvedValueOnce ( [
243+ makeDirent ( "10-projects" , true ) ,
244+ makeDirent ( "20-areas" , true ) ,
245+ makeDirent ( "note.md" , false ) ,
246+ ] ) ;
247+ const result = await listFolders ( OPTIONS , 1 ) ;
248+ expect ( result ) . toEqual ( {
249+ ok : true ,
250+ value : [ "10-projects" , "20-areas" ] ,
251+ } ) ;
252+ } ) ;
253+
254+ it ( "depth 0 returns unlimited (all folders)" , async ( ) => {
255+ readdirMock
256+ . mockResolvedValueOnce ( [ makeDirent ( "a" , true ) ] )
257+ . mockResolvedValueOnce ( [ makeDirent ( "b" , true ) ] )
258+ . mockResolvedValueOnce ( [ makeDirent ( "c" , true ) ] )
259+ . mockResolvedValueOnce ( [ ] ) ;
260+ const result = await listFolders ( OPTIONS , 0 ) ;
261+ expect ( result ) . toEqual ( {
262+ ok : true ,
263+ value : [ "a" , "a/b" , "a/b/c" ] ,
264+ } ) ;
265+ } ) ;
266+
267+ it ( "filters dot-prefixed directories" , async ( ) => {
268+ readdirMock . mockResolvedValueOnce ( [
269+ makeDirent ( ".obsidian" , true ) ,
270+ makeDirent ( ".git" , true ) ,
271+ makeDirent ( "notes" , true ) ,
272+ ] ) ;
273+ const result = await listFolders ( OPTIONS , 1 ) ;
274+ expect ( result ) . toEqual ( { ok : true , value : [ "notes" ] } ) ;
275+ } ) ;
276+
277+ it ( "enforces allowed list" , async ( ) => {
278+ const options = { ...OPTIONS , allowed : [ "notes" ] } ;
279+ readdirMock . mockResolvedValueOnce ( [ makeDirent ( "sub" , true ) ] ) . mockResolvedValueOnce ( [ ] ) ;
280+ const result = await listFolders ( options , 2 ) ;
281+ expect ( result ) . toEqual ( { ok : true , value : [ "notes" , "notes/sub" ] } ) ;
282+ } ) ;
283+
284+ it ( "includes allowed roots themselves" , async ( ) => {
285+ const options = { ...OPTIONS , allowed : [ "10-projects" , "20-areas" ] } ;
286+ readdirMock
287+ . mockResolvedValueOnce ( [ makeDirent ( "calunga" , true ) ] )
288+ . mockResolvedValueOnce ( [ makeDirent ( "career" , true ) ] ) ;
289+ const result = await listFolders ( options , 1 ) ;
290+ expect ( result ) . toEqual ( {
291+ ok : true ,
292+ value : [ "10-projects" , "10-projects/calunga" , "20-areas" , "20-areas/career" ] ,
293+ } ) ;
294+ } ) ;
295+
296+ it ( "enforces blocked list" , async ( ) => {
297+ const options = { ...OPTIONS , blocked : [ "private" ] } ;
298+ readdirMock . mockResolvedValueOnce ( [ makeDirent ( "notes" , true ) , makeDirent ( "private" , true ) ] ) ;
299+ const result = await listFolders ( options , 1 ) ;
300+ expect ( result ) . toEqual ( { ok : true , value : [ "notes" ] } ) ;
301+ } ) ;
302+
303+ it ( "skips unreadable directories" , async ( ) => {
304+ const options = { ...OPTIONS , allowed : [ "bad" , "good" ] } ;
305+ readdirMock
306+ . mockRejectedValueOnce ( new Error ( "ENOENT" ) )
307+ . mockResolvedValueOnce ( [ makeDirent ( "sub" , true ) ] )
308+ . mockResolvedValueOnce ( [ ] ) ;
309+ const result = await listFolders ( options , 2 ) ;
310+ expect ( result ) . toEqual ( { ok : true , value : [ "bad" , "good" , "good/sub" ] } ) ;
311+ } ) ;
312+
313+ it ( "returns empty array for empty vault" , async ( ) => {
314+ readdirMock . mockResolvedValueOnce ( [ ] ) ;
315+ const result = await listFolders ( OPTIONS , 1 ) ;
316+ expect ( result ) . toEqual ( { ok : true , value : [ ] } ) ;
317+ } ) ;
318+
319+ it ( "returns sorted results" , async ( ) => {
320+ readdirMock . mockResolvedValueOnce ( [
321+ makeDirent ( "zebra" , true ) ,
322+ makeDirent ( "alpha" , true ) ,
323+ makeDirent ( "mid" , true ) ,
324+ ] ) ;
325+ const result = await listFolders ( OPTIONS , 1 ) ;
326+ expect ( result ) . toEqual ( { ok : true , value : [ "alpha" , "mid" , "zebra" ] } ) ;
327+ } ) ;
198328} ) ;
199329
200330describe ( "statVirtualFile" , ( ) => {
0 commit comments