Merge pull request #746 from bytecii/feat/box-dropbox-io-slots
fix(box,dropbox): wire the du slot in python, drop the fake find slot in ts (#609 T1-I)
This commit is contained in:
@@ -45,7 +45,9 @@
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"ssh"
|
||||
"ssh",
|
||||
"box",
|
||||
"dropbox"
|
||||
],
|
||||
"command": "printf hi > /data/mtprobe.txt; find /data -name mtprobe.txt -mtime -1",
|
||||
"expect": {
|
||||
@@ -62,7 +64,9 @@
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"ssh"
|
||||
"ssh",
|
||||
"box",
|
||||
"dropbox"
|
||||
],
|
||||
"command": "find /data -name mtprobe.txt -mtime +5; echo code=$?",
|
||||
"expect": {
|
||||
@@ -79,7 +83,9 @@
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"ssh"
|
||||
"ssh",
|
||||
"box",
|
||||
"dropbox"
|
||||
],
|
||||
"command": "touch -d 2020-01-01 /data/mtprobe.txt; find /data -name mtprobe.txt -mtime +5",
|
||||
"expect": {
|
||||
@@ -96,7 +102,9 @@
|
||||
"disk",
|
||||
"redis",
|
||||
"opfs",
|
||||
"ssh"
|
||||
"ssh",
|
||||
"box",
|
||||
"dropbox"
|
||||
],
|
||||
"command": "rm /data/mtprobe.txt; echo done",
|
||||
"expect": {
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.commands.builtin.generic_bind import CommandIO
|
||||
from mirage.commands.builtin.generic_bind import CommandIO, DuOps
|
||||
from mirage.core.box.copy import copy as _copy
|
||||
from mirage.core.box.create import create as _create
|
||||
from mirage.core.box.du import entries as _du_entries
|
||||
from mirage.core.box.du import size as _du_size
|
||||
from mirage.core.box.exists import exists as _exists
|
||||
from mirage.core.box.mkdir import mkdir as _mkdir
|
||||
from mirage.core.box.read import read as _read
|
||||
@@ -38,6 +40,11 @@ IO = CommandIO(
|
||||
stat=_stat,
|
||||
is_mounted=lambda a: True,
|
||||
local=False,
|
||||
# Own the du walk instead of taking the builder's, which is capped at
|
||||
# max_du_entries and reports a partial total past it. A Box tree over
|
||||
# that cap is ordinary, and a silently wrong total is worse than a
|
||||
# slow one; this matches the typescript table.
|
||||
du=DuOps(size=_du_size, entries=_du_entries),
|
||||
write=_write,
|
||||
exists=_exists,
|
||||
mkdir=_mkdir,
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.commands.builtin.generic_bind import CommandIO
|
||||
from mirage.commands.builtin.generic_bind import CommandIO, DuOps
|
||||
from mirage.core.dropbox.copy import copy as _copy
|
||||
from mirage.core.dropbox.create import create as _create
|
||||
from mirage.core.dropbox.du import entries as _du_entries
|
||||
from mirage.core.dropbox.du import size as _du_size
|
||||
from mirage.core.dropbox.exists import exists as _exists
|
||||
from mirage.core.dropbox.mkdir import mkdir as _mkdir
|
||||
from mirage.core.dropbox.read import read as _read
|
||||
@@ -28,7 +30,7 @@ from mirage.core.dropbox.unlink import unlink as _unlink
|
||||
from mirage.core.dropbox.write import write_bytes as _write
|
||||
|
||||
# copy_v2 copies folder subtrees server-side, so dir_copy is the same
|
||||
# call as copy. du falls back to the generic readdir+stat walk.
|
||||
# call as copy.
|
||||
IO = CommandIO(
|
||||
readdir=_readdir,
|
||||
read_bytes=_read,
|
||||
@@ -37,6 +39,10 @@ IO = CommandIO(
|
||||
stat=_stat,
|
||||
is_mounted=lambda a: True,
|
||||
local=False,
|
||||
# Own the du walk instead of taking the builder's, which is capped at
|
||||
# max_du_entries and reports a partial total past it. See the Box
|
||||
# table; list_folder's recursive mode would make this a real pushdown.
|
||||
du=DuOps(size=_du_size, entries=_du_entries),
|
||||
write=_write,
|
||||
exists=_exists,
|
||||
mkdir=_mkdir,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.core.dropbox.du.entries import entries
|
||||
from mirage.core.dropbox.du.size import size
|
||||
|
||||
__all__ = ["entries", "size"]
|
||||
@@ -0,0 +1,46 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.accessor.dropbox import DropboxAccessor
|
||||
from mirage.cache.index import NULL_INDEX, IndexCacheStore
|
||||
from mirage.core.dropbox.du.walk import walk
|
||||
from mirage.core.dropbox.stat import stat
|
||||
from mirage.types import FileType, PathSpec
|
||||
|
||||
|
||||
async def entries(
|
||||
accessor: DropboxAccessor,
|
||||
path: PathSpec,
|
||||
index: IndexCacheStore = NULL_INDEX,
|
||||
) -> tuple[list[tuple[str, int]], int]:
|
||||
"""Per-file sizes under a path plus their total.
|
||||
|
||||
A file has no tree to walk, so it reports no entries and the caller
|
||||
falls back to its own size.
|
||||
|
||||
Args:
|
||||
accessor (DropboxAccessor): Dropbox accessor.
|
||||
path (PathSpec): target path.
|
||||
index (IndexCacheStore): path->metadata index cache.
|
||||
"""
|
||||
try:
|
||||
info = await stat(accessor, path, index)
|
||||
except FileNotFoundError:
|
||||
info = None
|
||||
if info is not None and info.type != FileType.DIRECTORY:
|
||||
return [], info.size or 0
|
||||
found: list[tuple[str, int]] = []
|
||||
total = await walk(accessor, path, index, found)
|
||||
found.sort()
|
||||
return found, total
|
||||
@@ -0,0 +1,33 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.accessor.dropbox import DropboxAccessor
|
||||
from mirage.cache.index import NULL_INDEX, IndexCacheStore
|
||||
from mirage.core.dropbox.du.walk import walk
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
async def size(
|
||||
accessor: DropboxAccessor,
|
||||
path: PathSpec,
|
||||
index: IndexCacheStore = NULL_INDEX,
|
||||
) -> int:
|
||||
"""Recursive byte size of everything under a path.
|
||||
|
||||
Args:
|
||||
accessor (DropboxAccessor): Dropbox accessor.
|
||||
path (PathSpec): target path.
|
||||
index (IndexCacheStore): path->metadata index cache.
|
||||
"""
|
||||
return await walk(accessor, path, index, None)
|
||||
@@ -0,0 +1,60 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.accessor.dropbox import DropboxAccessor
|
||||
from mirage.cache.index import IndexCacheStore
|
||||
from mirage.core.dropbox.readdir import readdir
|
||||
from mirage.core.dropbox.stat import stat
|
||||
from mirage.types import FileType, PathSpec
|
||||
from mirage.utils.key_prefix import mount_key, mount_prefix_of
|
||||
|
||||
|
||||
async def walk(
|
||||
accessor: DropboxAccessor,
|
||||
path: PathSpec,
|
||||
index: IndexCacheStore,
|
||||
results: list[tuple[str, int]] | None,
|
||||
) -> int:
|
||||
"""Sum file sizes under a path, optionally collecting each one.
|
||||
|
||||
Args:
|
||||
accessor (DropboxAccessor): Dropbox accessor.
|
||||
path (PathSpec): directory or file to walk.
|
||||
index (IndexCacheStore): path->metadata index cache.
|
||||
results (list[tuple[str, int]] | None): when given, collects
|
||||
mount-relative (path, size) pairs for each file found.
|
||||
"""
|
||||
try:
|
||||
info = await stat(accessor, path, index)
|
||||
except FileNotFoundError:
|
||||
return 0
|
||||
prefix = mount_prefix_of(path.virtual, path.resource_path)
|
||||
if info.type != FileType.DIRECTORY:
|
||||
size = info.size or 0
|
||||
if results is not None:
|
||||
results.append(("/" + mount_key(path.virtual, prefix), size))
|
||||
return size
|
||||
try:
|
||||
children = await readdir(accessor, path, index)
|
||||
except FileNotFoundError:
|
||||
return 0
|
||||
total = 0
|
||||
for child in children:
|
||||
trimmed = child.rstrip("/")
|
||||
child_spec = PathSpec(virtual=trimmed,
|
||||
directory=trimmed,
|
||||
resolved=False,
|
||||
resource_path=mount_key(trimmed, prefix))
|
||||
total += await walk(accessor, child_spec, index, results)
|
||||
return total
|
||||
@@ -0,0 +1,98 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.dropbox import DropboxAccessor
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.dropbox._client import DropboxTokenManager
|
||||
from mirage.core.dropbox.du import entries
|
||||
from mirage.resource.dropbox.config import DropboxConfig
|
||||
from mirage.types import PathSpec
|
||||
|
||||
_TREE = {
|
||||
"": [{
|
||||
".tag": "folder",
|
||||
"id": "id:data",
|
||||
"name": "data",
|
||||
"path_display": "/data",
|
||||
}],
|
||||
"/data": [
|
||||
{
|
||||
".tag": "folder",
|
||||
"id": "id:sub",
|
||||
"name": "sub",
|
||||
"path_display": "/data/sub",
|
||||
},
|
||||
{
|
||||
".tag": "file",
|
||||
"id": "id:a",
|
||||
"name": "a.txt",
|
||||
"path_display": "/data/a.txt",
|
||||
"size": 27,
|
||||
"server_modified": "2026-04-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
"/data/sub": [{
|
||||
".tag": "file",
|
||||
"id": "id:b",
|
||||
"name": "b.txt",
|
||||
"path_display": "/data/sub/b.txt",
|
||||
"size": 12,
|
||||
"server_modified": "2026-04-01T00:00:00Z",
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
async def _fake_list(_tm, path):
|
||||
return _TREE[path]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor():
|
||||
config = DropboxConfig(client_id="c", client_secret="s", refresh_token="r")
|
||||
return DropboxAccessor(config, DropboxTokenManager(config))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
return RAMIndexCacheStore()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entries_lists_files_with_total(accessor, index):
|
||||
with patch("mirage.core.dropbox.readdir.list_folder", new=_fake_list):
|
||||
found, total = await entries(
|
||||
accessor,
|
||||
PathSpec(resource_path="data", virtual="/data", directory="/"),
|
||||
index)
|
||||
assert found == [
|
||||
("/data/a.txt", 27),
|
||||
("/data/sub/b.txt", 12),
|
||||
]
|
||||
assert total == 39
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entries_on_file_returns_empty(accessor, index):
|
||||
with patch("mirage.core.dropbox.readdir.list_folder", new=_fake_list):
|
||||
found, total = await entries(
|
||||
accessor,
|
||||
PathSpec(resource_path="data/a.txt",
|
||||
virtual="/data/a.txt",
|
||||
directory="/data"), index)
|
||||
assert found == []
|
||||
assert total == 27
|
||||
@@ -0,0 +1,92 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.dropbox import DropboxAccessor
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.dropbox._client import DropboxTokenManager
|
||||
from mirage.core.dropbox.du import size
|
||||
from mirage.resource.dropbox.config import DropboxConfig
|
||||
from mirage.types import PathSpec
|
||||
|
||||
_TREE = {
|
||||
"": [{
|
||||
".tag": "folder",
|
||||
"id": "id:data",
|
||||
"name": "data",
|
||||
"path_display": "/data",
|
||||
}],
|
||||
"/data": [
|
||||
{
|
||||
".tag": "folder",
|
||||
"id": "id:sub",
|
||||
"name": "sub",
|
||||
"path_display": "/data/sub",
|
||||
},
|
||||
{
|
||||
".tag": "file",
|
||||
"id": "id:a",
|
||||
"name": "a.txt",
|
||||
"path_display": "/data/a.txt",
|
||||
"size": 27,
|
||||
"server_modified": "2026-04-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
"/data/sub": [{
|
||||
".tag": "file",
|
||||
"id": "id:b",
|
||||
"name": "b.txt",
|
||||
"path_display": "/data/sub/b.txt",
|
||||
"size": 12,
|
||||
"server_modified": "2026-04-01T00:00:00Z",
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
async def _fake_list(_tm, path):
|
||||
return _TREE[path]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor():
|
||||
config = DropboxConfig(client_id="c", client_secret="s", refresh_token="r")
|
||||
return DropboxAccessor(config, DropboxTokenManager(config))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
return RAMIndexCacheStore()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_size_walks_directory_tree(accessor, index):
|
||||
with patch("mirage.core.dropbox.readdir.list_folder", new=_fake_list):
|
||||
total = await size(
|
||||
accessor,
|
||||
PathSpec(resource_path="data", virtual="/data", directory="/"),
|
||||
index)
|
||||
assert total == 39
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_size_missing_path_is_zero(accessor, index):
|
||||
with patch("mirage.core.dropbox.readdir.list_folder", new=_fake_list):
|
||||
total = await size(
|
||||
accessor,
|
||||
PathSpec(resource_path="ghost", virtual="/ghost", directory="/"),
|
||||
index)
|
||||
assert total == 0
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"command_io": {
|
||||
"box": {
|
||||
"slots": "typescript pushes du and find down to the Box API while python takes the capped readdir walk (core/box/du/ is written but unwired); python range-reads while typescript's core read has no window. Both halves of T1-I."
|
||||
"slots": "python range-reads; the typescript core read has no window argument."
|
||||
},
|
||||
"dropbox": {
|
||||
"slots": "same as box: du/find are pushed down in typescript only, and the range read is python only."
|
||||
"slots": "python range-reads; the typescript core read has no window argument."
|
||||
},
|
||||
"gdrive": {
|
||||
"slots": "python range-reads; the typescript core read has no window argument."
|
||||
|
||||
@@ -426,6 +426,7 @@
|
||||
"copy",
|
||||
"create",
|
||||
"dir_copy",
|
||||
"du",
|
||||
"exists",
|
||||
"is_mounted",
|
||||
"mkdir",
|
||||
@@ -534,6 +535,7 @@
|
||||
"slots": [
|
||||
"copy",
|
||||
"create",
|
||||
"du",
|
||||
"exists",
|
||||
"is_mounted",
|
||||
"mkdir",
|
||||
|
||||
@@ -334,7 +334,6 @@
|
||||
"dir_copy",
|
||||
"du",
|
||||
"exists",
|
||||
"find",
|
||||
"is_mounted",
|
||||
"mkdir",
|
||||
"read_bytes",
|
||||
@@ -416,7 +415,6 @@
|
||||
"create",
|
||||
"du",
|
||||
"exists",
|
||||
"find",
|
||||
"is_mounted",
|
||||
"mkdir",
|
||||
"read_bytes",
|
||||
|
||||
@@ -428,7 +428,6 @@
|
||||
"dir_copy",
|
||||
"du",
|
||||
"exists",
|
||||
"find",
|
||||
"is_mounted",
|
||||
"mkdir",
|
||||
"read_bytes",
|
||||
@@ -537,7 +536,6 @@
|
||||
"create",
|
||||
"du",
|
||||
"exists",
|
||||
"find",
|
||||
"is_mounted",
|
||||
"mkdir",
|
||||
"read_bytes",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
import type { BoxAccessor } from '../../../accessor/box.ts'
|
||||
import { size as boxDu, entries as boxDuAll } from '../../../core/box/du/index.ts'
|
||||
import { find as boxFind } from '../../../core/box/find.ts'
|
||||
import { read as boxRead, stream as boxStream } from '../../../core/box/read.ts'
|
||||
import { readdir as boxReaddir } from '../../../core/box/readdir.ts'
|
||||
import { stat as boxStat } from '../../../core/box/stat.ts'
|
||||
@@ -51,5 +50,11 @@ export const BOX_IO: CommandIO<BoxAccessor> = {
|
||||
dirCopy: boxCopy,
|
||||
create: boxCreate,
|
||||
truncate: boxTruncate,
|
||||
find: boxFind,
|
||||
// No `find` slot on purpose, matching python. A native op is worth
|
||||
// wiring only when it pushes the search down to the API; Box has no
|
||||
// such call, so the op could only re-walk readdir/stat — which is what
|
||||
// the find and cp builders already do, except they walk with the
|
||||
// mount's own index, the namespace stat overlay (`find -mtime` after a
|
||||
// `touch -d`) and the symlink table (`-empty`). Wiring the walk as an
|
||||
// op silently dropped all three.
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import { copy as dropboxCopy } from '../../../core/dropbox/copy.ts'
|
||||
import { create as dropboxCreate } from '../../../core/dropbox/create.ts'
|
||||
import { size as dropboxDu, entries as dropboxDuAll } from '../../../core/dropbox/du/index.ts'
|
||||
import { exists as dropboxExists } from '../../../core/dropbox/exists.ts'
|
||||
import { find as dropboxFind } from '../../../core/dropbox/find.ts'
|
||||
import { mkdir as dropboxMkdir } from '../../../core/dropbox/mkdir.ts'
|
||||
import { read as dropboxRead, stream as dropboxStream } from '../../../core/dropbox/read.ts'
|
||||
import { readdir as dropboxReaddir } from '../../../core/dropbox/readdir.ts'
|
||||
@@ -49,5 +48,8 @@ export const DROPBOX_IO: CommandIO<DropboxAccessor> = {
|
||||
// rejects an existing destination).
|
||||
copy: dropboxCopy,
|
||||
create: dropboxCreate,
|
||||
find: dropboxFind,
|
||||
// No `find` slot on purpose, matching python — see the note in the Box
|
||||
// table. Dropbox does have a recursive list_folder, so a real pushdown
|
||||
// is possible here later; the op this replaced was not one, it was the
|
||||
// builders' own walk minus the index, the stat overlay and the links.
|
||||
}
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import { mountKey } from '../../utils/key_prefix.ts'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as ReaddirModule from './readdir.ts'
|
||||
import type * as StatModule from './stat.ts'
|
||||
|
||||
vi.mock('./readdir.ts', async () => {
|
||||
const actual = await vi.importActual<typeof ReaddirModule>('./readdir.ts')
|
||||
return { ...actual, readdir: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('./stat.ts', async () => {
|
||||
const actual = await vi.importActual<typeof StatModule>('./stat.ts')
|
||||
return { ...actual, stat: vi.fn() }
|
||||
})
|
||||
|
||||
import { BoxAccessor } from '../../accessor/box.ts'
|
||||
import { FileStat, FileType, PathSpec } from '../../types.ts'
|
||||
import type { BoxTokenManager } from './_client.ts'
|
||||
import type { IndexCacheStore } from '../../cache/index/store.ts'
|
||||
import type { FindOptions } from '../../resource/base.ts'
|
||||
import { walkFind } from '../generic/find.ts'
|
||||
import * as readdirMod from './readdir.ts'
|
||||
import * as statMod from './stat.ts'
|
||||
|
||||
async function find(
|
||||
accessor: BoxAccessor,
|
||||
path: PathSpec,
|
||||
options: FindOptions = {},
|
||||
index?: IndexCacheStore,
|
||||
): Promise<string[]> {
|
||||
return walkFind(
|
||||
path,
|
||||
{
|
||||
readdir: (spec, idx) => readdirMod.readdir(accessor, spec, idx),
|
||||
stat: (spec, idx) => statMod.stat(accessor, spec, idx),
|
||||
},
|
||||
options,
|
||||
index,
|
||||
)
|
||||
}
|
||||
|
||||
const STUB_TM = {} as BoxTokenManager
|
||||
|
||||
function makeAccessor(): BoxAccessor {
|
||||
return new BoxAccessor({ tokenManager: STUB_TM })
|
||||
}
|
||||
|
||||
function enoent(p: string): Error {
|
||||
const e = new Error(`ENOENT: ${p}`) as Error & { code: string }
|
||||
e.code = 'ENOENT'
|
||||
return e
|
||||
}
|
||||
|
||||
function mockTree(tree: Record<string, string[]>): void {
|
||||
vi.mocked(readdirMod.readdir).mockImplementation((_accessor, spec) => {
|
||||
const children = tree[spec.virtual]
|
||||
if (children === undefined) return Promise.reject(enoent(spec.virtual))
|
||||
return Promise.resolve(children)
|
||||
})
|
||||
}
|
||||
|
||||
function mockStats(stats: Record<string, { size?: number; modified?: string }>): void {
|
||||
vi.mocked(statMod.stat).mockImplementation((_accessor, spec) => {
|
||||
const entry = stats[spec.virtual]
|
||||
if (entry === undefined) return Promise.reject(enoent(spec.virtual))
|
||||
const name = spec.virtual.split('/').pop() ?? ''
|
||||
return Promise.resolve(
|
||||
new FileStat({
|
||||
name,
|
||||
size: entry.size ?? null,
|
||||
modified: entry.modified ?? null,
|
||||
type: entry.size === undefined ? FileType.DIRECTORY : FileType.TEXT,
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const TREE: Record<string, string[]> = {
|
||||
'/': ['/docs/', '/notes.txt'],
|
||||
'/docs': ['/docs/readme.md', '/docs/inner/'],
|
||||
'/docs/inner': ['/docs/inner/deep.md'],
|
||||
}
|
||||
|
||||
const ROOT = new PathSpec({ resourcePath: '', virtual: '/', directory: '/' })
|
||||
|
||||
const SIZES: Record<string, { size?: number; modified?: string }> = {
|
||||
'/docs': { modified: '2026-01-05T00:00:00Z' },
|
||||
'/docs/inner': { modified: '2026-01-01T00:00:00Z' },
|
||||
'/docs/inner/deep.md': { size: 500_000, modified: '2026-01-01T00:00:00Z' },
|
||||
'/docs/readme.md': { size: 2048, modified: '2026-01-05T00:00:00Z' },
|
||||
'/notes.txt': { size: 10, modified: '2026-01-10T00:00:00Z' },
|
||||
}
|
||||
|
||||
describe('box core find', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(readdirMod.readdir).mockReset()
|
||||
vi.mocked(statMod.stat).mockReset()
|
||||
mockStats(SIZES)
|
||||
})
|
||||
|
||||
it('walks recursively returning files and dirs sorted without trailing slashes', async () => {
|
||||
mockTree(TREE)
|
||||
const out = await find(makeAccessor(), ROOT)
|
||||
expect(out).toEqual([
|
||||
'/docs',
|
||||
'/docs/inner',
|
||||
'/docs/inner/deep.md',
|
||||
'/docs/readme.md',
|
||||
'/notes.txt',
|
||||
])
|
||||
})
|
||||
|
||||
it('filters by name glob', async () => {
|
||||
mockTree(TREE)
|
||||
const out = await find(makeAccessor(), ROOT, { name: '*.md' })
|
||||
expect(out).toEqual(['/docs/inner/deep.md', '/docs/readme.md'])
|
||||
})
|
||||
|
||||
it('filters by type f and type d', async () => {
|
||||
mockTree(TREE)
|
||||
const files = await find(makeAccessor(), ROOT, { type: 'f' })
|
||||
expect(files).toEqual(['/docs/inner/deep.md', '/docs/readme.md', '/notes.txt'])
|
||||
const dirs = await find(makeAccessor(), ROOT, { type: 'd' })
|
||||
expect(dirs).toEqual(['/docs', '/docs/inner'])
|
||||
})
|
||||
|
||||
it('honors maxDepth and minDepth', async () => {
|
||||
mockTree(TREE)
|
||||
const shallow = await find(makeAccessor(), ROOT, { maxDepth: 1 })
|
||||
expect(shallow).toEqual(['/docs', '/notes.txt'])
|
||||
const deep = await find(makeAccessor(), ROOT, { minDepth: 2 })
|
||||
expect(deep).toEqual(['/docs/inner', '/docs/inner/deep.md', '/docs/readme.md'])
|
||||
})
|
||||
|
||||
it('strips the mount prefix from returned keys', async () => {
|
||||
mockTree({
|
||||
'/mnt/box': ['/mnt/box/docs/', '/mnt/box/notes.txt'],
|
||||
'/mnt/box/docs': ['/mnt/box/docs/readme.md'],
|
||||
})
|
||||
const root = new PathSpec({
|
||||
virtual: '/mnt/box',
|
||||
directory: '/mnt/box',
|
||||
resourcePath: mountKey('/mnt/box', '/mnt/box'),
|
||||
})
|
||||
const out = await find(makeAccessor(), root)
|
||||
expect(out).toEqual(['/docs', '/docs/readme.md', '/notes.txt'])
|
||||
})
|
||||
|
||||
it('does not stat slash-marked directory entries', async () => {
|
||||
mockTree(TREE)
|
||||
await find(makeAccessor(), ROOT, { name: '*.md' })
|
||||
const statted = vi.mocked(statMod.stat).mock.calls.map((c) => c[1].virtual)
|
||||
expect(statted).not.toContain('/docs')
|
||||
expect(statted).not.toContain('/docs/inner')
|
||||
})
|
||||
|
||||
it('filters by minSize with directories contributing size 0', async () => {
|
||||
mockTree(TREE)
|
||||
mockStats(SIZES)
|
||||
const out = await find(makeAccessor(), ROOT, { minSize: 1024 })
|
||||
expect(out).toEqual(['/docs/inner/deep.md', '/docs/readme.md'])
|
||||
})
|
||||
|
||||
it('filters files by maxSize', async () => {
|
||||
mockTree(TREE)
|
||||
mockStats(SIZES)
|
||||
const out = await find(makeAccessor(), ROOT, { maxSize: 100, type: 'f' })
|
||||
expect(out).toEqual(['/notes.txt'])
|
||||
})
|
||||
|
||||
it('stats the start path plus files for type detection and size filtering only', async () => {
|
||||
mockTree(TREE)
|
||||
await find(makeAccessor(), ROOT, { name: '*.md', minSize: 1024 })
|
||||
const statted = [...new Set(vi.mocked(statMod.stat).mock.calls.map((c) => c[1].virtual))]
|
||||
// '/' is the start-point stat that emits the search root itself.
|
||||
expect(statted.sort()).toEqual(['/', '/docs/inner/deep.md', '/docs/readme.md', '/notes.txt'])
|
||||
})
|
||||
|
||||
it('filters by mtimeMin and mtimeMax on files and dirs', async () => {
|
||||
mockTree(TREE)
|
||||
mockStats(SIZES)
|
||||
const cutoff = Date.parse('2026-01-03T00:00:00Z') / 1000
|
||||
const recent = await find(makeAccessor(), ROOT, { mtimeMin: cutoff })
|
||||
expect(recent).toEqual(['/docs', '/docs/readme.md', '/notes.txt'])
|
||||
const old = await find(makeAccessor(), ROOT, { mtimeMax: cutoff })
|
||||
expect(old).toEqual(['/docs/inner', '/docs/inner/deep.md'])
|
||||
})
|
||||
|
||||
it('excludes entries without a modified time when mtime filter is set', async () => {
|
||||
mockTree(TREE)
|
||||
mockStats({ ...SIZES, '/notes.txt': { size: 10 } })
|
||||
const out = await find(makeAccessor(), ROOT, { mtimeMin: 0 })
|
||||
expect(out).toEqual(['/docs', '/docs/inner', '/docs/inner/deep.md', '/docs/readme.md'])
|
||||
})
|
||||
|
||||
it('filters by pathPattern against the full path', async () => {
|
||||
mockTree(TREE)
|
||||
const out = await find(makeAccessor(), ROOT, { pathPattern: '*/inner/*' })
|
||||
expect(out).toEqual(['/docs/inner/deep.md'])
|
||||
})
|
||||
|
||||
it('matches pathPattern against the display path', async () => {
|
||||
mockTree({
|
||||
'/mnt/box': ['/mnt/box/docs/', '/mnt/box/notes.txt'],
|
||||
'/mnt/box/docs': ['/mnt/box/docs/readme.md'],
|
||||
})
|
||||
const root = new PathSpec({
|
||||
virtual: '/mnt/box',
|
||||
directory: '/mnt/box',
|
||||
resourcePath: mountKey('/mnt/box', '/mnt/box'),
|
||||
})
|
||||
const out = await find(makeAccessor(), root, { pathPattern: '/mnt/box/docs/*' })
|
||||
expect(out).toEqual(['/docs/readme.md'])
|
||||
})
|
||||
|
||||
it('matches any of orNames patterns', async () => {
|
||||
mockTree(TREE)
|
||||
const out = await find(makeAccessor(), ROOT, { orNames: ['*.txt', 'deep.*'] })
|
||||
expect(out).toEqual(['/docs/inner/deep.md', '/notes.txt'])
|
||||
})
|
||||
|
||||
it('excludes names matching nameExclude', async () => {
|
||||
mockTree(TREE)
|
||||
const out = await find(makeAccessor(), ROOT, { nameExclude: '*.md' })
|
||||
expect(out).toEqual(['/docs', '/docs/inner', '/notes.txt'])
|
||||
})
|
||||
|
||||
it('detects directories via stat when cached readdir entries lack trailing slashes', async () => {
|
||||
mockTree({
|
||||
'/': ['/docs', '/notes.txt'],
|
||||
'/docs': ['/docs/readme.md'],
|
||||
})
|
||||
mockStats(SIZES)
|
||||
const files = await find(makeAccessor(), ROOT, { type: 'f' })
|
||||
expect(files).toEqual(['/docs/readme.md', '/notes.txt'])
|
||||
const dirs = await find(makeAccessor(), ROOT, { type: 'd' })
|
||||
expect(dirs).toEqual(['/docs'])
|
||||
})
|
||||
|
||||
it('sorts by codepoint, not locale', async () => {
|
||||
mockTree({ '/': ['/Zeta.txt', '/alpha.txt'] })
|
||||
mockStats({ '/Zeta.txt': { size: 1 }, '/alpha.txt': { size: 1 } })
|
||||
const out = await find(makeAccessor(), ROOT)
|
||||
expect(out).toEqual(['/Zeta.txt', '/alpha.txt'])
|
||||
})
|
||||
|
||||
it('keeps a child whose readdir raises ENOENT but stops descending', async () => {
|
||||
mockTree({ '/': ['/ghost/'] })
|
||||
const out = await find(makeAccessor(), ROOT)
|
||||
expect(out).toEqual(['/ghost'])
|
||||
})
|
||||
|
||||
it('propagates non-ENOENT readdir errors', async () => {
|
||||
vi.mocked(readdirMod.readdir).mockImplementation((_accessor, spec) => {
|
||||
if (spec.virtual === '/') return Promise.resolve(['/bad/'])
|
||||
return Promise.reject(new Error('rate limited'))
|
||||
})
|
||||
await expect(find(makeAccessor(), ROOT)).rejects.toThrow('rate limited')
|
||||
})
|
||||
|
||||
it('parses naive modified timestamps as UTC', async () => {
|
||||
mockTree({ '/': ['/naive.txt'] })
|
||||
mockStats({ '/naive.txt': { size: 1, modified: '2026-01-05T00:00:00' } })
|
||||
const out = await find(makeAccessor(), ROOT, {
|
||||
mtimeMin: Date.parse('2026-01-04T23:30:00Z') / 1000,
|
||||
mtimeMax: Date.parse('2026-01-05T00:30:00Z') / 1000,
|
||||
})
|
||||
expect(out).toEqual(['/naive.txt'])
|
||||
})
|
||||
})
|
||||
@@ -1,42 +0,0 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import type { BoxAccessor } from '../../accessor/box.ts'
|
||||
import { RAMIndexCacheStore } from '../../cache/index/ram.ts'
|
||||
import type { FindOptions } from '../../resource/base.ts'
|
||||
import type { PathSpec } from '../../types.ts'
|
||||
import { walkFind } from '../generic/find.ts'
|
||||
import { readdir } from './readdir.ts'
|
||||
import { stat } from './stat.ts'
|
||||
|
||||
export function find(
|
||||
accessor: BoxAccessor,
|
||||
path: PathSpec,
|
||||
options: FindOptions,
|
||||
): Promise<string[]> {
|
||||
// Box readdir/stat resolve folder ids through an index cache. The generic
|
||||
// cp/find builders may call find without threading one (unlike Python,
|
||||
// whose cp threads the resource index), so walk with a scratch index that
|
||||
// this call populates as it descends.
|
||||
const idx = new RAMIndexCacheStore({ ttl: 86_400 })
|
||||
return walkFind(
|
||||
path,
|
||||
{
|
||||
readdir: (spec, i) => readdir(accessor, spec, i),
|
||||
stat: (spec, i) => stat(accessor, spec, i),
|
||||
},
|
||||
options,
|
||||
idx,
|
||||
)
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import { mountKey } from '../../utils/key_prefix.ts'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as ReaddirModule from './readdir.ts'
|
||||
import type * as StatModule from './stat.ts'
|
||||
|
||||
vi.mock('./readdir.ts', async () => {
|
||||
const actual = await vi.importActual<typeof ReaddirModule>('./readdir.ts')
|
||||
return { ...actual, readdir: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('./stat.ts', async () => {
|
||||
const actual = await vi.importActual<typeof StatModule>('./stat.ts')
|
||||
return { ...actual, stat: vi.fn() }
|
||||
})
|
||||
|
||||
import { DropboxAccessor } from '../../accessor/dropbox.ts'
|
||||
import { FileStat, FileType, PathSpec } from '../../types.ts'
|
||||
import type { DropboxTokenManager } from './_client.ts'
|
||||
import type { IndexCacheStore } from '../../cache/index/store.ts'
|
||||
import type { FindOptions } from '../../resource/base.ts'
|
||||
import { walkFind } from '../generic/find.ts'
|
||||
import * as readdirMod from './readdir.ts'
|
||||
import * as statMod from './stat.ts'
|
||||
|
||||
async function find(
|
||||
accessor: DropboxAccessor,
|
||||
path: PathSpec,
|
||||
options: FindOptions = {},
|
||||
index?: IndexCacheStore,
|
||||
): Promise<string[]> {
|
||||
return walkFind(
|
||||
path,
|
||||
{
|
||||
readdir: (spec, idx) => readdirMod.readdir(accessor, spec, idx),
|
||||
stat: (spec, idx) => statMod.stat(accessor, spec, idx),
|
||||
},
|
||||
options,
|
||||
index,
|
||||
)
|
||||
}
|
||||
|
||||
const STUB_TM = {} as DropboxTokenManager
|
||||
|
||||
function makeAccessor(): DropboxAccessor {
|
||||
return new DropboxAccessor({ tokenManager: STUB_TM })
|
||||
}
|
||||
|
||||
function enoent(p: string): Error {
|
||||
const e = new Error(`ENOENT: ${p}`) as Error & { code: string }
|
||||
e.code = 'ENOENT'
|
||||
return e
|
||||
}
|
||||
|
||||
function mockTree(tree: Record<string, string[]>): void {
|
||||
vi.mocked(readdirMod.readdir).mockImplementation((_accessor, spec) => {
|
||||
const children = tree[spec.virtual]
|
||||
if (children === undefined) return Promise.reject(enoent(spec.virtual))
|
||||
return Promise.resolve(children)
|
||||
})
|
||||
}
|
||||
|
||||
function mockStats(stats: Record<string, { size?: number; modified?: string }>): void {
|
||||
vi.mocked(statMod.stat).mockImplementation((_accessor, spec) => {
|
||||
const entry = stats[spec.virtual]
|
||||
if (entry === undefined) return Promise.reject(enoent(spec.virtual))
|
||||
const name = spec.virtual.split('/').pop() ?? ''
|
||||
return Promise.resolve(
|
||||
new FileStat({
|
||||
name,
|
||||
size: entry.size ?? null,
|
||||
modified: entry.modified ?? null,
|
||||
type: entry.size === undefined ? FileType.DIRECTORY : FileType.TEXT,
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const TREE: Record<string, string[]> = {
|
||||
'/': ['/docs/', '/notes.txt'],
|
||||
'/docs': ['/docs/readme.md', '/docs/inner/'],
|
||||
'/docs/inner': ['/docs/inner/deep.md'],
|
||||
}
|
||||
|
||||
const ROOT = new PathSpec({ resourcePath: '', virtual: '/', directory: '/' })
|
||||
|
||||
const SIZES: Record<string, { size?: number; modified?: string }> = {
|
||||
'/docs': { modified: '2026-01-05T00:00:00Z' },
|
||||
'/docs/inner': { modified: '2026-01-01T00:00:00Z' },
|
||||
'/docs/inner/deep.md': { size: 500_000, modified: '2026-01-01T00:00:00Z' },
|
||||
'/docs/readme.md': { size: 2048, modified: '2026-01-05T00:00:00Z' },
|
||||
'/notes.txt': { size: 10, modified: '2026-01-10T00:00:00Z' },
|
||||
}
|
||||
|
||||
describe('dropbox core find', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(readdirMod.readdir).mockReset()
|
||||
vi.mocked(statMod.stat).mockReset()
|
||||
mockStats(SIZES)
|
||||
})
|
||||
|
||||
it('walks recursively returning files and dirs sorted without trailing slashes', async () => {
|
||||
mockTree(TREE)
|
||||
const out = await find(makeAccessor(), ROOT)
|
||||
expect(out).toEqual([
|
||||
'/docs',
|
||||
'/docs/inner',
|
||||
'/docs/inner/deep.md',
|
||||
'/docs/readme.md',
|
||||
'/notes.txt',
|
||||
])
|
||||
})
|
||||
|
||||
it('filters by name glob', async () => {
|
||||
mockTree(TREE)
|
||||
const out = await find(makeAccessor(), ROOT, { name: '*.md' })
|
||||
expect(out).toEqual(['/docs/inner/deep.md', '/docs/readme.md'])
|
||||
})
|
||||
|
||||
it('filters by type f and type d', async () => {
|
||||
mockTree(TREE)
|
||||
const files = await find(makeAccessor(), ROOT, { type: 'f' })
|
||||
expect(files).toEqual(['/docs/inner/deep.md', '/docs/readme.md', '/notes.txt'])
|
||||
const dirs = await find(makeAccessor(), ROOT, { type: 'd' })
|
||||
expect(dirs).toEqual(['/docs', '/docs/inner'])
|
||||
})
|
||||
|
||||
it('honors maxDepth and minDepth', async () => {
|
||||
mockTree(TREE)
|
||||
const shallow = await find(makeAccessor(), ROOT, { maxDepth: 1 })
|
||||
expect(shallow).toEqual(['/docs', '/notes.txt'])
|
||||
const deep = await find(makeAccessor(), ROOT, { minDepth: 2 })
|
||||
expect(deep).toEqual(['/docs/inner', '/docs/inner/deep.md', '/docs/readme.md'])
|
||||
})
|
||||
|
||||
it('strips the mount prefix from returned keys', async () => {
|
||||
mockTree({
|
||||
'/mnt/dbx': ['/mnt/dbx/docs/', '/mnt/dbx/notes.txt'],
|
||||
'/mnt/dbx/docs': ['/mnt/dbx/docs/readme.md'],
|
||||
})
|
||||
const root = new PathSpec({
|
||||
virtual: '/mnt/dbx',
|
||||
directory: '/mnt/dbx',
|
||||
resourcePath: mountKey('/mnt/dbx', '/mnt/dbx'),
|
||||
})
|
||||
const out = await find(makeAccessor(), root)
|
||||
expect(out).toEqual(['/docs', '/docs/readme.md', '/notes.txt'])
|
||||
})
|
||||
|
||||
it('does not stat slash-marked directory entries', async () => {
|
||||
mockTree(TREE)
|
||||
await find(makeAccessor(), ROOT, { name: '*.md' })
|
||||
const statted = vi.mocked(statMod.stat).mock.calls.map((c) => c[1].virtual)
|
||||
expect(statted).not.toContain('/docs')
|
||||
expect(statted).not.toContain('/docs/inner')
|
||||
})
|
||||
|
||||
it('filters by minSize with directories contributing size 0', async () => {
|
||||
mockTree(TREE)
|
||||
mockStats(SIZES)
|
||||
const out = await find(makeAccessor(), ROOT, { minSize: 1024 })
|
||||
expect(out).toEqual(['/docs/inner/deep.md', '/docs/readme.md'])
|
||||
})
|
||||
|
||||
it('filters files by maxSize', async () => {
|
||||
mockTree(TREE)
|
||||
mockStats(SIZES)
|
||||
const out = await find(makeAccessor(), ROOT, { maxSize: 100, type: 'f' })
|
||||
expect(out).toEqual(['/notes.txt'])
|
||||
})
|
||||
|
||||
it('stats the start path plus files for type detection and size filtering only', async () => {
|
||||
mockTree(TREE)
|
||||
await find(makeAccessor(), ROOT, { name: '*.md', minSize: 1024 })
|
||||
const statted = [...new Set(vi.mocked(statMod.stat).mock.calls.map((c) => c[1].virtual))]
|
||||
// '/' is the start-point stat that emits the search root itself.
|
||||
expect(statted.sort()).toEqual(['/', '/docs/inner/deep.md', '/docs/readme.md', '/notes.txt'])
|
||||
})
|
||||
|
||||
it('filters by mtimeMin and mtimeMax on files and dirs', async () => {
|
||||
mockTree(TREE)
|
||||
mockStats(SIZES)
|
||||
const cutoff = Date.parse('2026-01-03T00:00:00Z') / 1000
|
||||
const recent = await find(makeAccessor(), ROOT, { mtimeMin: cutoff })
|
||||
expect(recent).toEqual(['/docs', '/docs/readme.md', '/notes.txt'])
|
||||
const old = await find(makeAccessor(), ROOT, { mtimeMax: cutoff })
|
||||
expect(old).toEqual(['/docs/inner', '/docs/inner/deep.md'])
|
||||
})
|
||||
|
||||
it('excludes entries without a modified time when mtime filter is set', async () => {
|
||||
mockTree(TREE)
|
||||
mockStats({ ...SIZES, '/notes.txt': { size: 10 } })
|
||||
const out = await find(makeAccessor(), ROOT, { mtimeMin: 0 })
|
||||
expect(out).toEqual(['/docs', '/docs/inner', '/docs/inner/deep.md', '/docs/readme.md'])
|
||||
})
|
||||
|
||||
it('filters by pathPattern against the full path', async () => {
|
||||
mockTree(TREE)
|
||||
const out = await find(makeAccessor(), ROOT, { pathPattern: '*/inner/*' })
|
||||
expect(out).toEqual(['/docs/inner/deep.md'])
|
||||
})
|
||||
|
||||
it('matches pathPattern against the display path', async () => {
|
||||
mockTree({
|
||||
'/mnt/dbx': ['/mnt/dbx/docs/', '/mnt/dbx/notes.txt'],
|
||||
'/mnt/dbx/docs': ['/mnt/dbx/docs/readme.md'],
|
||||
})
|
||||
const root = new PathSpec({
|
||||
virtual: '/mnt/dbx',
|
||||
directory: '/mnt/dbx',
|
||||
resourcePath: mountKey('/mnt/dbx', '/mnt/dbx'),
|
||||
})
|
||||
const out = await find(makeAccessor(), root, { pathPattern: '/mnt/dbx/docs/*' })
|
||||
expect(out).toEqual(['/docs/readme.md'])
|
||||
})
|
||||
|
||||
it('matches any of orNames patterns', async () => {
|
||||
mockTree(TREE)
|
||||
const out = await find(makeAccessor(), ROOT, { orNames: ['*.txt', 'deep.*'] })
|
||||
expect(out).toEqual(['/docs/inner/deep.md', '/notes.txt'])
|
||||
})
|
||||
|
||||
it('excludes names matching nameExclude', async () => {
|
||||
mockTree(TREE)
|
||||
const out = await find(makeAccessor(), ROOT, { nameExclude: '*.md' })
|
||||
expect(out).toEqual(['/docs', '/docs/inner', '/notes.txt'])
|
||||
})
|
||||
|
||||
it('detects directories via stat when cached readdir entries lack trailing slashes', async () => {
|
||||
mockTree({
|
||||
'/': ['/docs', '/notes.txt'],
|
||||
'/docs': ['/docs/readme.md'],
|
||||
})
|
||||
mockStats(SIZES)
|
||||
const files = await find(makeAccessor(), ROOT, { type: 'f' })
|
||||
expect(files).toEqual(['/docs/readme.md', '/notes.txt'])
|
||||
const dirs = await find(makeAccessor(), ROOT, { type: 'd' })
|
||||
expect(dirs).toEqual(['/docs'])
|
||||
})
|
||||
|
||||
it('sorts by codepoint, not locale', async () => {
|
||||
mockTree({ '/': ['/Zeta.txt', '/alpha.txt'] })
|
||||
mockStats({ '/Zeta.txt': { size: 1 }, '/alpha.txt': { size: 1 } })
|
||||
const out = await find(makeAccessor(), ROOT)
|
||||
expect(out).toEqual(['/Zeta.txt', '/alpha.txt'])
|
||||
})
|
||||
|
||||
it('keeps a child whose readdir raises ENOENT but stops descending', async () => {
|
||||
mockTree({ '/': ['/ghost/'] })
|
||||
const out = await find(makeAccessor(), ROOT)
|
||||
expect(out).toEqual(['/ghost'])
|
||||
})
|
||||
|
||||
it('propagates non-ENOENT readdir errors', async () => {
|
||||
vi.mocked(readdirMod.readdir).mockImplementation((_accessor, spec) => {
|
||||
if (spec.virtual === '/') return Promise.resolve(['/bad/'])
|
||||
return Promise.reject(new Error('rate limited'))
|
||||
})
|
||||
await expect(find(makeAccessor(), ROOT)).rejects.toThrow('rate limited')
|
||||
})
|
||||
|
||||
it('parses naive modified timestamps as UTC', async () => {
|
||||
mockTree({ '/': ['/naive.txt'] })
|
||||
mockStats({ '/naive.txt': { size: 1, modified: '2026-01-05T00:00:00' } })
|
||||
const out = await find(makeAccessor(), ROOT, {
|
||||
mtimeMin: Date.parse('2026-01-04T23:30:00Z') / 1000,
|
||||
mtimeMax: Date.parse('2026-01-05T00:30:00Z') / 1000,
|
||||
})
|
||||
expect(out).toEqual(['/naive.txt'])
|
||||
})
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import type { DropboxAccessor } from '../../accessor/dropbox.ts'
|
||||
import { RAMIndexCacheStore } from '../../cache/index/ram.ts'
|
||||
import type { FindOptions } from '../../resource/base.ts'
|
||||
import type { PathSpec } from '../../types.ts'
|
||||
import { walkFind } from '../generic/find.ts'
|
||||
import { readdir } from './readdir.ts'
|
||||
import { stat } from './stat.ts'
|
||||
|
||||
// Same readdir/stat walk the generic fallback uses, wired as a find op
|
||||
// so the cp builder (which plans recursive copies through find) works.
|
||||
export function find(
|
||||
accessor: DropboxAccessor,
|
||||
path: PathSpec,
|
||||
options: FindOptions,
|
||||
): Promise<string[]> {
|
||||
// An index-less dropbox stat hits files/get_metadata per child, so a
|
||||
// bare walk is an N+1 API sweep. Walk with a scratch index that the
|
||||
// readdirs populate as the walk descends, like the Box find does.
|
||||
const idx = new RAMIndexCacheStore({ ttl: 86_400 })
|
||||
return walkFind(
|
||||
path,
|
||||
{
|
||||
readdir: (spec, index) => readdir(accessor, spec, index),
|
||||
stat: (spec, index) => stat(accessor, spec, index),
|
||||
},
|
||||
options,
|
||||
idx,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user