|
|
import os |
|
|
from pathlib import Path |
|
|
from dataclasses import dataclass, asdict |
|
|
from typing import List, Optional, Literal |
|
|
|
|
|
@dataclass |
|
|
class FileSystemNode: |
|
|
name: str |
|
|
type: Literal["file", "directory"] |
|
|
path: str |
|
|
size: Optional[int] = None |
|
|
children: Optional[List['FileSystemNode']] = None |
|
|
|
|
|
class FileSystemVisitor: |
|
|
""" |
|
|
A deterministic visitor for the file system. |
|
|
Acts exactly like ast.NodeVisitor but for folders. |
|
|
""" |
|
|
|
|
|
|
|
|
IGNORED_DIRS = { |
|
|
"__pycache__", "node_modules", ".git", ".vscode", ".idea", |
|
|
"dist", "build", "coverage", ".venv", "venv", "env" |
|
|
} |
|
|
|
|
|
IGNORED_FILES = { |
|
|
".DS_Store", "package-lock.json", "yarn.lock", "pnpm-lock.yaml" |
|
|
} |
|
|
|
|
|
def visit(self, root_path: str, max_depth: int = 4) -> dict: |
|
|
""" |
|
|
Public entry point. Returns a Dictionary representing the tree. |
|
|
""" |
|
|
path = Path(root_path).resolve() |
|
|
if not path.exists(): |
|
|
raise ValueError(f"Path not found: {root_path}") |
|
|
|
|
|
|
|
|
node = self._visit_node(path, current_depth=0, max_depth=max_depth) |
|
|
return asdict(node) if node else {} |
|
|
|
|
|
def _visit_node(self, path: Path, current_depth: int, max_depth: int) -> Optional[FileSystemNode]: |
|
|
""" |
|
|
Recursive logic. |
|
|
If directory: returns Node with children. |
|
|
If file: returns Node (leaf). |
|
|
""" |
|
|
|
|
|
if path.name in self.IGNORED_DIRS or path.name in self.IGNORED_FILES: |
|
|
return None |
|
|
|
|
|
|
|
|
if path.is_file(): |
|
|
return FileSystemNode( |
|
|
name=path.name, |
|
|
type="file", |
|
|
path=str(path), |
|
|
size=path.stat().st_size |
|
|
) |
|
|
|
|
|
|
|
|
if path.is_dir(): |
|
|
|
|
|
if current_depth >= max_depth: |
|
|
return FileSystemNode( |
|
|
name=path.name, |
|
|
type="directory", |
|
|
path=str(path), |
|
|
children=[] |
|
|
) |
|
|
|
|
|
children_nodes = [] |
|
|
try: |
|
|
|
|
|
|
|
|
entries = sorted(path.iterdir(), key=lambda p: (p.is_file(), p.name.lower())) |
|
|
|
|
|
for entry in entries: |
|
|
child = self._visit_node(entry, current_depth + 1, max_depth) |
|
|
if child: |
|
|
children_nodes.append(child) |
|
|
|
|
|
except PermissionError: |
|
|
pass |
|
|
|
|
|
return FileSystemNode( |
|
|
name=path.name, |
|
|
type="directory", |
|
|
path=str(path), |
|
|
children=children_nodes |
|
|
) |
|
|
|
|
|
return None |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TreeFormatter: |
|
|
def format(self, node_dict: dict) -> str: |
|
|
"""Converts the JSON tree into a visual string.""" |
|
|
lines = [] |
|
|
self._render(node_dict, lines, "", is_last=True) |
|
|
return "\n".join(lines) |
|
|
|
|
|
def _render(self, node: dict, lines: list, prefix: str, is_last: bool): |
|
|
|
|
|
connector = "βββ " if is_last else "βββ " |
|
|
lines.append(f"{prefix}{connector}{node['name']}") |
|
|
|
|
|
prefix += " " if is_last else "β " |
|
|
|
|
|
children = node.get("children", []) or [] |
|
|
count = len(children) |
|
|
|
|
|
for i, child in enumerate(children): |
|
|
self._render(child, lines, prefix, i == count - 1) |
|
|
|
|
|
|