Difficulty: 🟡 Medium · Time: ~55 min · Patterns: Composite, Visitor, Iterator, Facade
The cleanest Composite problem there is. The design decision is made in the first thirty seconds — how a directory stores its children — and almost every difficulty later traces back to getting that one wrong.
Build a file system in memory: files, directories, paths, and the commands you
would expect — ls, mkdir, read, write, rm, mv, du, find.
- Directories contain files and other directories, to any depth.
- A directory's size is everything beneath it.
- Resolve absolute paths, including
.and... - The usual commands, with specific errors rather than generic ones.
- Search by name, by extension, or by an arbitrary predicate.
- Render the tree.
- Adding a new operation must not mean editing the node classes.
- Single-threaded, entirely in memory.
- No symlinks, permissions or file handles — all follow-ups.
- Paths are absolute. Sizes are UTF-8 byte counts.
classDiagram
class FileSystemNode {
<<abstract>>
+str name
+Directory parent
+size int
+is_directory bool
+path str
+depth int
+accept(visitor)*
}
class File {
+str content
+extension str
+write(content) int
+append(content) int
}
class Directory {
-Dict~str, FileSystemNode~ children
+add(node) FileSystemNode
+remove(name) FileSystemNode
+get(name) FileSystemNode
+walk() Iterator
+files() Iterator
}
class FileSystem {
<<facade>>
+Directory root
+resolve(path) FileSystemNode
+mkdir(path, parents) Directory
+write(path, content) File
+ls(path) List~str~
+rm(path, recursive)
+move(source, destination)
+du(path) DiskUsageVisitor
+find(path, name, extension) List~str~
+tree(path) str
}
class NodeVisitor {
<<abstract>>
+visit_file(file)*
+visit_directory(directory)*
+run(node) NodeVisitor
}
class DiskUsageVisitor {
+int total_bytes
+int file_count
+Dict~str, int~ by_extension
}
class FindVisitor {
+Callable predicate
+List matches
}
class TreeRenderVisitor {
+List~str~ lines
}
FileSystemNode <|-- File
FileSystemNode <|-- Directory
Directory o-- "*" FileSystemNode : children
FileSystemNode --> Directory : parent
FileSystem o-- Directory : root
FileSystem ..> NodeVisitor : runs
NodeVisitor <|-- DiskUsageVisitor
NodeVisitor <|-- FindVisitor
NodeVisitor <|-- TreeRenderVisitor
NodeVisitor ..> FileSystemNode : visits
A directory can hold two kinds of thing. The obvious model:
class Directory:
files: list[File]
subdirectories: list[Directory] # don'tIt reads fine and it is wrong. Every traversal is now written twice, once per list, forever — size, search, rendering, move, delete. The two copies drift, and the bug is always in whichever one you wrote second.
Composite says: one list, one type.
class Directory(FileSystemNode):
_children: dict[str, FileSystemNode]File and Directory both implement size, path, accept. A caller holding
a FileSystemNode never asks which it has:
return sum(child.size for child in self._children.values())That line is the entire recursive size implementation, and it works because a
file answers size just as readily as a directory does.
is_directory still exists for the few callers that genuinely must
distinguish — ls marks directories with a slash. But it is the exception, and
it is explicit rather than an isinstance scattered through the traversals.
@property
def size(self) -> int:
return sum(child.size for child in self._children.values())Caching it means invalidating up the entire ancestor chain on every write, and the one path that forgets leaves a directory permanently reporting the wrong number — with nothing to indicate it. Derive first; cache only when a profiler says to, and then test the invalidation harder than the calculation.
Disk usage, search and rendering are the same traversal with different
bookkeeping. Put each on the nodes and every new operation edits both File
and Directory, which slowly become a junk drawer of unrelated methods.
As visitors, a new operation is one new class and nothing existing changes.
The trade is the one Visitor always makes, and it is worth naming out loud:
| Composite-only | With Visitor | |
|---|---|---|
| New operation | edit every node class | one new class |
| New node type | one new class | edit every visitor |
For a file system that is the right way round — there are two node types and there will always be two, while operations keep arriving. For a design where new node types are the common change, Visitor is the wrong pattern and you should say so.
b.add(a) # where a is b's parentThe tree now has a cycle, and the next recursive size call never returns. It
is an easy mistake for a user of the API to make, so add walks the parent
chain and refuses:
if isinstance(node, Directory) and self._is_inside(node):
raise ValueError(f"Cannot add {node.path} into its own descendant {self.path}")if part == "..":
if parts:
parts.pop()
continuePopping an empty list is a no-op, so /../../.. resolves to the root rather
than walking off the top of the tree. Resolving afterwards means handling "we
went above the root" as a special case; resolving during means it cannot happen.
move validates the destination before detaching the node:
if name in parent:
raise FileExistsError(...) # nothing has moved yet
node.parent.remove(node.name)The other order orphans the node on failure: removed from where it was, never added to where it was going, and unreachable from the root. A test asserts the node is still in place after a refused move.
walk() yields. A deep tree should not have to be materialised in full just to
find the first match, and find stops caring how big the tree is.
cd problems/file-system && python3 src/main.py/ [220]
├── docs/ [9]
│ └── guide.md [9]
├── src/ [80]
│ └── demo/ [80]
│ ├── utils/ [34]
│ │ └── text.py [34]
│ ├── __init__.py [0]
│ ├── core.py [25]
│ └── models.py [21]
├── tests/ [65]
...
220 bytes across 9 files in 6 directories
by extension: md 51b, py 145b, toml 24b
Moving a directory carries everything under it:
/lib/demo/utils/text.py exists: True
/src now holds: (nothing)
/nope.txt PathNotFound: No such file or directory: /nope.txt
/README.md/inner NotADirectory: /README.md is a file, so /README.md/inner cannot exist
Or explore it yourself with --interactive.
python3 -m pytest problems/file-system -v- Symbolic links. The tree stops being a tree. What does recursive size do when a link points at an ancestor?
- Permissions and ownership. On the node, or a separate table? What does
finddo with a directory it cannot read? - Snapshots of a subtree, copy-on-write. Nodes become immutable, and sharing structure between versions gets interesting.
- File handles with seek and offsets.
Filecurrently has no notion of a reader. - Now cache directory sizes. The interesting half is invalidation: which nodes, in what order, and what happens on a failed write?
- Concurrent readers and writers. A lock per directory, or one per tree?