File Tree: Main implementation of helper + tests

Rafael Uzarowski committed Nov 9, 2025 at 14:47 UTC c2b772875eda7191b8bd2370be743106cc9d68d2
19 files changed +3853 -10
python/helpers/files.py
+571 -10
@@ -1,25 +1,29 @@
1 +from __future__ import annotations
2 +
3 from abc import ABC, abstractmethod
4 +from collections import deque
5 +from dataclasses import dataclass
6 +from datetime import datetime, timezone
7 from fnmatch import fnmatch
8 import json
4 -from ntpath import isabs
9 import os
6 -import sys
10 import re
11 import base64
12 import shutil
13 import tempfile
11 -from typing import Any
14 +from typing import Any, Callable, Iterable, Literal, Optional, Sequence, Type, cast
15 import zipfile
13 -import importlib
14 -import importlib.util
15 -import inspect
16 import glob
17 import mimetypes
18
19 +from pathspec import PathSpec
20 +
21
22 class VariablesPlugin(ABC):
23 @abstractmethod
22 - def get_variables(self, file: str, backup_dirs: list[str] | None = None) -> dict[str, Any]: # type: ignore
24 + def get_variables(
25 + self, file: str, backup_dirs: list[str] | None = None
26 + ) -> dict[str, Any]: # type: ignore[override]
27 pass
28
29
@@ -44,11 +48,14 @@ def load_plugin_variables(
48
49 from python.helpers import extract_tools
50
51 + plugin_base: Any = VariablesPlugin
52 classes = extract_tools.load_classes_from_file(
48 - plugin_file, VariablesPlugin, one_per_file=False
53 + plugin_file, plugin_base, one_per_file=False
54 )
55 for cls in classes:
51 - return cls().get_variables(file, backup_dirs) # type: ignore < abstract class here is ok, it is always a subclass
56 + if isinstance(cls, type) and issubclass(cls, VariablesPlugin):
57 + plugin_cls = cast(Type[VariablesPlugin], cls)
58 + return plugin_cls().get_variables(file, backup_dirs)
59
60 # load python code and extract variables variables from it
61 # module = None
@@ -347,7 +354,7 @@ def delete_dir(relative_path: str):
354
355 # try again after changing permissions
356 shutil.rmtree(abs_path, ignore_errors=True)
350 - except:
357 + except Exception:
358 # suppress all errors - we're ensuring no errors propagate
359 pass
360
@@ -530,3 +537,557 @@ def read_text_files_in_dir(
537 except Exception:
538 continue
539 return result
540 +
541 +
542 +SORT_BY_NAME = "name"
543 +SORT_BY_CREATED = "created"
544 +SORT_BY_MODIFIED = "modified"
545 +
546 +SORT_ASC = "asc"
547 +SORT_DESC = "desc"
548 +
549 +OUTPUT_MODE_STRING = "string"
550 +OUTPUT_MODE_FLAT = "flat"
551 +OUTPUT_MODE_NESTED = "nested"
552 +
553 +
554 +def file_tree(
555 + relative_path: str,
556 + *,
557 + max_depth: int = 0,
558 + max_lines: int = 0,
559 + folders_first: bool = True,
560 + max_folders: int | None = None,
561 + max_files: int | None = None,
562 + sort: tuple[str, str] = (SORT_BY_MODIFIED, SORT_DESC),
563 + ignore: str | None = None,
564 + output_mode: str = OUTPUT_MODE_STRING,
565 +) -> str | list[dict]:
566 + """Render a directory tree relative to the repository base path.
567 +
568 + Parameters:
569 + relative_path: Base directory (relative to project root) to scan with :func:`get_abs_path`.
570 + max_depth: Maximum depth of traversal (0 = unlimited). Depth starts at 1 for root entries.
571 + max_lines: Global limit for rendered lines (0 = unlimited). When exceeded, the current depth
572 + finishes rendering before deeper levels are skipped.
573 + folders_first: When True, folders render before files within each directory.
574 + max_folders: Optional per-directory cap (0 = unlimited) on rendered folder entries before adding a
575 + ``# N more folders`` comment. When only a single folder exceeds the limit and ``max_folders`` is greater than zero, that folder is rendered
576 + directly instead of emitting a summary comment.
577 + max_files: Optional per-directory cap (0 = unlimited) on rendered file entries before adding a ``# N more files`` comment.
578 + As with folders, a single excess file is rendered when ``max_files`` is greater than zero.
579 + sort: Tuple of ``(key, direction)`` where key is one of :data:`SORT_BY_NAME`,
580 + :data:`SORT_BY_CREATED`, or :data:`SORT_BY_MODIFIED`; direction is :data:`SORT_ASC`
581 + or :data:`SORT_DESC`.
582 + ignore: Inline ``.gitignore`` content or ``file:`` reference. Examples::
583 +
584 + ignore=\"\"\"\\n*.pyc\\n__pycache__/\\n!important.py\\n\"\"\"
585 + ignore=\"file:.gitignore\" # relative to scan root
586 + ignore=\"file://.gitignore\" # URI-style relative path
587 + ignore=\"file:/abs/path/.gitignore\"
588 + ignore=\"file:///abs/path/.gitignore\"
589 +
590 + output_mode: One of :data:`OUTPUT_MODE_STRING`, :data:`OUTPUT_MODE_FLAT`, or
591 + :data:`OUTPUT_MODE_NESTED`.
592 +
593 + Returns:
594 + ``OUTPUT_MODE_STRING`` → ``str``: multi-line ASCII tree.
595 + ``OUTPUT_MODE_FLAT`` → ``list[dict]``: flattened sequence of TreeItem dictionaries.
596 + ``OUTPUT_MODE_NESTED`` → ``list[dict]``: nested TreeItem dictionaries where folders
597 + include ``items`` arrays.
598 +
599 + Notes:
600 + * The utility is synchronous; avoid calling from latency-sensitive async loops.
601 + * The ASCII renderer walks the established tree depth-first so connectors reflect parent/child structure,
602 + while traversal and limit calculations remain breadth-first by depth.
603 + * ``created`` and ``modified`` values in structured outputs are timezone-aware UTC
604 + :class:`datetime.datetime` objects::
605 +
606 + item = flat_items[0]
607 + iso = item[\"created\"].isoformat()
608 + epoch = item[\"created\"].timestamp()
609 +
610 + """
611 + abs_root = get_abs_path(relative_path)
612 +
613 + if not os.path.exists(abs_root):
614 + raise FileNotFoundError(f"Path does not exist: {relative_path!r}")
615 + if not os.path.isdir(abs_root):
616 + raise NotADirectoryError(f"Expected a directory, received: {relative_path!r}")
617 +
618 + sort_key, sort_direction = sort
619 + if sort_key not in {SORT_BY_NAME, SORT_BY_CREATED, SORT_BY_MODIFIED}:
620 + raise ValueError(f"Unsupported sort key: {sort_key!r}")
621 + if sort_direction not in {SORT_ASC, SORT_DESC}:
622 + raise ValueError(f"Unsupported sort direction: {sort_direction!r}")
623 + if output_mode not in {OUTPUT_MODE_STRING, OUTPUT_MODE_FLAT, OUTPUT_MODE_NESTED}:
624 + raise ValueError(f"Unsupported output mode: {output_mode!r}")
625 + if max_depth < 0:
626 + raise ValueError("max_depth must be >= 0")
627 + if max_lines < 0:
628 + raise ValueError("max_lines must be >= 0")
629 +
630 + ignore_spec = _resolve_ignore_patterns(ignore, abs_root)
631 +
632 + root_stat = os.stat(abs_root, follow_symlinks=False)
633 + root_name = os.path.basename(os.path.normpath(abs_root)) or os.path.basename(abs_root)
634 + root_node = _TreeEntry(
635 + name=root_name,
636 + level=0,
637 + item_type="folder",
638 + created=datetime.fromtimestamp(root_stat.st_ctime, tz=timezone.utc),
639 + modified=datetime.fromtimestamp(root_stat.st_mtime, tz=timezone.utc),
640 + parent=None,
641 + items=[],
642 + rel_path="",
643 + )
644 +
645 + queue: deque[tuple[_TreeEntry, str, int]] = deque([(root_node, abs_root, 1)])
646 + nodes_in_order: list[_TreeEntry] = []
647 + limit_level: Optional[int] = None
648 + visibility_cache: dict[str, bool] = {}
649 +
650 + def make_entry(entry: os.DirEntry, parent: _TreeEntry, level: int, item_type: Literal["file", "folder"]) -> _TreeEntry:
651 + stat = entry.stat(follow_symlinks=False)
652 + rel_path = os.path.relpath(entry.path, abs_root)
653 + rel_posix = _normalize_relative_path(rel_path)
654 + return _TreeEntry(
655 + name=entry.name,
656 + level=level,
657 + item_type=item_type,
658 + created=datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc),
659 + modified=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
660 + parent=parent,
661 + items=[] if item_type == "folder" else None,
662 + rel_path=rel_posix,
663 + )
664 +
665 + while queue:
666 + parent_node, current_dir, level = queue.popleft()
667 +
668 + if max_depth and level > max_depth:
669 + continue
670 +
671 + remaining_depth = max_depth - level if max_depth else -1
672 + folders, files = _list_directory_children(
673 + current_dir,
674 + abs_root,
675 + ignore_spec,
676 + max_depth_remaining=remaining_depth,
677 + cache=visibility_cache,
678 + )
679 +
680 + folder_entries = [make_entry(folder, parent_node, level, "folder") for folder in folders]
681 + file_entries = [make_entry(file_entry, parent_node, level, "file") for file_entry in files]
682 +
683 + children = _apply_sorting_and_limits(
684 + folder_entries,
685 + file_entries,
686 + folders_first=folders_first,
687 + sort=sort,
688 + max_folders=max_folders,
689 + max_files=max_files,
690 + directory_node=parent_node,
691 + )
692 +
693 + parent_node.items = children
694 + nodes_in_order.extend(children)
695 +
696 + if max_lines and limit_level is None and len(nodes_in_order) >= max_lines:
697 + limit_level = level
698 +
699 + for child in children:
700 + if child.item_type != "folder":
701 + continue
702 + if max_depth and level >= max_depth:
703 + continue
704 + if limit_level is not None and level >= limit_level:
705 + continue
706 + child_abs = os.path.join(current_dir, child.name)
707 + queue.append((child, child_abs, level + 1))
708 +
709 + pruned_nodes: list[_TreeEntry] = nodes_in_order
710 + if max_lines and limit_level is not None:
711 + _prune_nested_children(
712 + root_node,
713 + lambda entry: entry.level <= limit_level,
714 + )
715 + pruned_nodes = [node for node in nodes_in_order if node.level <= limit_level]
716 +
717 + visible_nodes: list[_TreeEntry]
718 + if max_lines and limit_level is None:
719 + visible_nodes = pruned_nodes[:max_lines]
720 + else:
721 + visible_nodes = pruned_nodes
722 +
723 + visible_ids = {id(node) for node in visible_nodes}
724 + if visible_ids:
725 + _prune_to_visible(root_node, visible_ids)
726 +
727 + _mark_last_flags(root_node)
728 + _refresh_render_metadata(root_node)
729 +
730 + def iter_visible() -> Iterable[_TreeEntry]:
731 + for node in _iter_depth_first(root_node.items or []):
732 + if not visible_ids or id(node) in visible_ids:
733 + yield node
734 +
735 + if output_mode == OUTPUT_MODE_STRING:
736 + display_name = relative_path.strip() or root_name
737 + root_line = f"{display_name.rstrip(os.sep)}/"
738 + lines = [root_line]
739 + for node in iter_visible():
740 + lines.append(node.text)
741 + return "\n".join(lines)
742 +
743 + if output_mode == OUTPUT_MODE_FLAT:
744 + return _build_tree_items_flat(list(iter_visible()))
745 +
746 + return _to_nested_structure(root_node.items or [])
747 +
748 +
749 +@dataclass(slots=True)
750 +class _TreeEntry:
751 + name: str
752 + level: int
753 + item_type: Literal["file", "folder", "comment"]
754 + created: datetime
755 + modified: datetime
756 + parent: Optional["_TreeEntry"] = None
757 + items: Optional[list["_TreeEntry"]] = None
758 + is_last: bool = False
759 + rel_path: str = ""
760 + text: str = ""
761 +
762 + def as_dict(self) -> dict[str, Any]:
763 + return {
764 + "name": self.name,
765 + "level": self.level,
766 + "type": self.item_type,
767 + "created": self.created,
768 + "modified": self.modified,
769 + "text": self.text,
770 + "items": [child.as_dict() for child in self.items] if self.items is not None else None,
771 + }
772 +
773 +
774 +def _normalize_relative_path(path: str) -> str:
775 + normalized = path.replace(os.sep, "/")
776 + if normalized in {".", ""}:
777 + return ""
778 + while normalized.startswith("./"):
779 + normalized = normalized[2:]
780 + return normalized
781 +
782 +
783 +def _directory_has_visible_entries(
784 + directory: str,
785 + root_abs_path: str,
786 + ignore_spec: PathSpec,
787 + cache: dict[str, bool],
788 + max_depth_remaining: int,
789 +) -> bool:
790 + if max_depth_remaining == 0:
791 + return False
792 +
793 + cached = cache.get(directory)
794 + if cached is not None:
795 + return cached
796 +
797 + try:
798 + with os.scandir(directory) as iterator:
799 + for entry in iterator:
800 + rel_path = os.path.relpath(entry.path, root_abs_path)
801 + rel_posix = _normalize_relative_path(rel_path)
802 + is_dir = entry.is_dir(follow_symlinks=False)
803 +
804 + if is_dir:
805 + ignored = ignore_spec.match_file(rel_posix) or ignore_spec.match_file(f"{rel_posix}/")
806 + if ignored:
807 + next_depth = max_depth_remaining - 1 if max_depth_remaining > 0 else -1
808 + if next_depth == 0:
809 + continue
810 + if _directory_has_visible_entries(
811 + entry.path,
812 + root_abs_path,
813 + ignore_spec,
814 + cache,
815 + next_depth,
816 + ):
817 + cache[directory] = True
818 + return True
819 + continue
820 + else:
821 + if ignore_spec.match_file(rel_posix):
822 + continue
823 +
824 + cache[directory] = True
825 + return True
826 + except FileNotFoundError:
827 + cache[directory] = False
828 + return False
829 +
830 + cache[directory] = False
831 + return False
832 +
833 +
834 +def _create_summary_comment(parent: _TreeEntry, noun: str, count: int) -> _TreeEntry:
835 + label = noun
836 + if count == 1 and noun.endswith("s"):
837 + label = noun[:-1]
838 + elif count > 1 and not noun.endswith("s"):
839 + label = f"{noun}s"
840 + return _TreeEntry(
841 + name=f"{count} more {label}",
842 + level=parent.level + 1,
843 + item_type="comment",
844 + created=parent.created,
845 + modified=parent.modified,
846 + parent=parent,
847 + items=None,
848 + rel_path=f"{parent.rel_path}#summary:{noun}:{count}",
849 + )
850 +
851 +
852 +def _prune_nested_children(node: _TreeEntry, predicate: Callable[[_TreeEntry], bool]) -> None:
853 + if node.items is None:
854 + return
855 + pruned: list[_TreeEntry] = []
856 + for child in node.items:
857 + if predicate(child):
858 + _prune_nested_children(child, predicate)
859 + pruned.append(child)
860 + node.items = pruned
861 +
862 +
863 +def _prune_to_visible(node: _TreeEntry, visible_ids: set[int]) -> None:
864 + if node.items is None:
865 + return
866 + filtered: list[_TreeEntry] = []
867 + for child in node.items:
868 + if not visible_ids or id(child) in visible_ids:
869 + _prune_to_visible(child, visible_ids)
870 + filtered.append(child)
871 + node.items = filtered
872 +
873 +
874 +def _mark_last_flags(node: _TreeEntry) -> None:
875 + if node.items is None:
876 + return
877 + total = len(node.items)
878 + for index, child in enumerate(node.items):
879 + child.is_last = index == total - 1
880 + _mark_last_flags(child)
881 +
882 +
883 +def _refresh_render_metadata(node: _TreeEntry) -> None:
884 + if node.items is None:
885 + return
886 + for child in node.items:
887 + child.text = _format_line(child)
888 + _refresh_render_metadata(child)
889 +
890 +
891 +def _resolve_ignore_patterns(ignore: str | None, root_abs_path: str) -> Optional[PathSpec]:
892 + if ignore is None:
893 + return None
894 +
895 + content: str
896 + if ignore.startswith("file:"):
897 + reference = ignore[5:]
898 + if reference.startswith("///"):
899 + reference_path = reference[2:]
900 + elif reference.startswith("//"):
901 + reference_path = os.path.join(root_abs_path, reference[2:])
902 + elif reference.startswith("/"):
903 + reference_path = reference
904 + else:
905 + reference_path = os.path.join(root_abs_path, reference)
906 +
907 + try:
908 + with open(reference_path, "r", encoding="utf-8") as handle:
909 + content = handle.read()
910 + except FileNotFoundError as exc:
911 + raise FileNotFoundError(f"Ignore file not found: {reference_path}") from exc
912 + else:
913 + content = ignore
914 +
915 + lines = [
916 + line.strip()
917 + for line in content.splitlines()
918 + if line.strip() and not line.strip().startswith("#")
919 + ]
920 +
921 + if not lines:
922 + return None
923 +
924 + return PathSpec.from_lines("gitwildmatch", lines)
925 +
926 +
927 +def _list_directory_children(
928 + directory: str,
929 + root_abs_path: str,
930 + ignore_spec: Optional[PathSpec],
931 + *,
932 + max_depth_remaining: int,
933 + cache: dict[str, bool],
934 +) -> tuple[list[os.DirEntry], list[os.DirEntry]]:
935 + folders: list[os.DirEntry] = []
936 + files: list[os.DirEntry] = []
937 +
938 + try:
939 + with os.scandir(directory) as iterator:
940 + for entry in iterator:
941 + if entry.name in (".", ".."):
942 + continue
943 + rel_path = os.path.relpath(entry.path, root_abs_path)
944 + rel_posix = _normalize_relative_path(rel_path)
945 + is_directory = entry.is_dir(follow_symlinks=False)
946 +
947 + if ignore_spec:
948 + if is_directory:
949 + ignored = ignore_spec.match_file(rel_posix) or ignore_spec.match_file(f"{rel_posix}/")
950 + if ignored:
951 + if _directory_has_visible_entries(
952 + entry.path,
953 + root_abs_path,
954 + ignore_spec,
955 + cache,
956 + max_depth_remaining - 1,
957 + ):
958 + folders.append(entry)
959 + continue
960 + else:
961 + if ignore_spec.match_file(rel_posix):
962 + continue
963 +
964 + if is_directory:
965 + folders.append(entry)
966 + else:
967 + files.append(entry)
968 + except FileNotFoundError:
969 + return ([], [])
970 +
971 + return (folders, files)
972 +
973 +
974 +def _apply_sorting_and_limits(
975 + folders: list[_TreeEntry],
976 + files: list[_TreeEntry],
977 + *,
978 + folders_first: bool,
979 + sort: tuple[str, str],
980 + max_folders: int | None,
981 + max_files: int | None,
982 + directory_node: _TreeEntry,
983 +) -> list[_TreeEntry]:
984 + sort_key, sort_dir = sort
985 + reverse = sort_dir == SORT_DESC
986 +
987 + def key_fn(node: _TreeEntry):
988 + if sort_key == SORT_BY_NAME:
989 + return node.name.casefold()
990 + if sort_key == SORT_BY_CREATED:
991 + return node.created
992 + return node.modified
993 +
994 + folders_sorted = sorted(folders, key=key_fn, reverse=reverse)
995 + files_sorted = sorted(files, key=key_fn, reverse=reverse)
996 + combined: list[_TreeEntry] = []
997 +
998 + def append_group(group: list[_TreeEntry], limit: int | None, noun: str) -> None:
999 + if limit == 0:
1000 + limit = None
1001 + if not group:
1002 + return
1003 + if limit is None:
1004 + combined.extend(group)
1005 + return
1006 +
1007 + limit = max(limit, 0)
1008 + visible = group[:limit]
1009 + combined.extend(visible)
1010 +
1011 + overflow = group[limit:]
1012 + if not overflow:
1013 + return
1014 +
1015 + if len(overflow) == 1 and limit > 0:
1016 + combined.append(overflow[0])
1017 + return
1018 +
1019 + combined.append(
1020 + _create_summary_comment(
1021 + directory_node,
1022 + noun,
1023 + len(overflow),
1024 + )
1025 + )
1026 +
1027 + if folders_first:
1028 + append_group(folders_sorted, max_folders, "folder")
1029 + append_group(files_sorted, max_files, "file")
1030 + else:
1031 + append_group(files_sorted, max_files, "file")
1032 + append_group(folders_sorted, max_folders, "folder")
1033 +
1034 + return combined
1035 +
1036 +
1037 +def _format_line(node: _TreeEntry) -> str:
1038 + segments: list[str] = []
1039 + ancestor = node.parent
1040 + while ancestor and ancestor.parent is not None:
1041 + segments.append(" " if ancestor.is_last else "│ ")
1042 + ancestor = ancestor.parent
1043 + segments.reverse()
1044 +
1045 + connector = "└── " if node.is_last else "├── "
1046 + if node.item_type == "folder":
1047 + label = f"{node.name}/"
1048 + elif node.item_type == "comment":
1049 + label = f"# {node.name}"
1050 + else:
1051 + label = node.name
1052 +
1053 + return "".join(segments) + connector + label
1054 +
1055 +
1056 +def _build_tree_items_flat(items: Sequence[_TreeEntry]) -> list[dict]:
1057 + return [
1058 + {
1059 + "name": node.name,
1060 + "level": node.level,
1061 + "type": node.item_type,
1062 + "created": node.created,
1063 + "modified": node.modified,
1064 + "text": node.text,
1065 + "items": None,
1066 + }
1067 + for node in items
1068 + ]
1069 +
1070 +
1071 +def _to_nested_structure(items: Sequence[_TreeEntry]) -> list[dict]:
1072 + def convert(node: _TreeEntry) -> dict:
1073 + children = None
1074 + if node.items is not None:
1075 + children = [convert(child) for child in node.items]
1076 + return {
1077 + "name": node.name,
1078 + "level": node.level,
1079 + "type": node.item_type,
1080 + "created": node.created,
1081 + "modified": node.modified,
1082 + "text": node.text,
1083 + "items": children,
1084 + }
1085 +
1086 + return [convert(item) for item in items]
1087 +
1088 +
1089 +def _iter_depth_first(items: Sequence[_TreeEntry]) -> Iterable[_TreeEntry]:
1090 + for node in items:
1091 + yield node
1092 + if node.items:
1093 + yield from _iter_depth_first(node.items)
specs/001-file-tree-utility/checklists/requirements.md new
+35
@@ -0,0 +1,35 @@
1 +# Specification Quality Checklist: File Tree Utility
2 +
3 +**Purpose**: Validate specification completeness and quality before proceeding to planning
4 +**Created**: 2025-11-08
5 +**Feature**: [/home/rafael/Workspace/Repos/rafael/a0-local/specs/001-file-tree-utility/spec.md](/home/rafael/Workspace/Repos/rafael/a0-local/specs/001-file-tree-utility/spec.md)
6 +
7 +## Content Quality
8 +
9 +- [X] No implementation details (languages, frameworks, APIs)
10 +- [X] Focused on user value and business needs
11 +- [X] Written for non-technical stakeholders
12 +- [X] All mandatory sections completed
13 +
14 +## Requirement Completeness
15 +
16 +- [X] No [NEEDS CLARIFICATION] markers remain
17 +- [X] Requirements are testable and unambiguous
18 +- [X] Success criteria are measurable
19 +- [X] Success criteria are technology-agnostic (no implementation details)
20 +- [X] All acceptance scenarios are defined
21 +- [X] Edge cases are identified
22 +- [X] Scope is clearly bounded
23 +- [X] Dependencies and assumptions identified
24 +
25 +## Feature Readiness
26 +
27 +- [X] All functional requirements have clear acceptance criteria
28 +- [X] User scenarios cover primary flows
29 +- [X] Feature meets measurable outcomes defined in Success Criteria
30 +- [X] No implementation details leak into specification
31 +
32 +## Notes
33 +
34 +- Implementation-specific guidance (constants, helper layout, docstring content, filesystem helper usage) now lives in the contract and plan; the specification itself remains technology-agnostic.
35 +- If scope changes (e.g., additional output modes or metadata), revisit FR-002/FR-003 and Success Criteria accordingly.
specs/001-file-tree-utility/contracts/file_tree.md new
+103
@@ -0,0 +1,103 @@
1 +# Contract: file_tree() Utility
2 +
3 +## Location
4 +- Module: `python/helpers/files.py`
5 +- Exported function: `file_tree()`
6 +- Predefined constants (declared directly before function definition):
7 + - Sort keys: `SORT_BY_NAME`, `SORT_BY_CREATED`, `SORT_BY_MODIFIED`
8 + - Sort directions: `SORT_ASC`, `SORT_DESC`
9 + - Output modes: `OUTPUT_MODE_STRING`, `OUTPUT_MODE_FLAT`, `OUTPUT_MODE_NESTED`
10 +
11 +## Signature (proposed)
12 +```python
13 +def file_tree(
14 + relative_path: str,
15 + *,
16 + max_depth: int = 0,
17 + max_lines: int = 0,
18 + folders_first: bool = True,
19 + max_folders: int | None = None,
20 + max_files: int | None = None,
21 + sort: tuple[str, str] = (SORT_BY_MODIFIED, SORT_DESC),
22 + ignore: str | None = None,
23 + output_mode: str = OUTPUT_MODE_STRING,
24 +) -> str | list[dict]:
25 + ...
26 +```
27 +
28 +## Parameters
29 +- `relative_path`: Base folder to scan. The implementation MUST resolve with `get_abs_path(relative_path)` from helpers.
30 +- `max_depth`: Maximum depth to scan; `0` means unlimited.
31 +- `max_lines`: Maximum total output lines across the whole render; `0` means unlimited.
32 +- `folders_first`: If `True`, group folders before files when rendering/sorting (default per Clarifications).
33 +- `max_folders`: Per-directory maximum number of folder entries to render before emitting a summary comment (e.g., `# 6 more folders`).
34 +- `max_files`: Per-directory maximum number of file entries to render before emitting a summary comment (e.g., `# 23 more files`).
35 +- `sort`: Tuple `(key, direction)`:
36 + - `key ∈ {SORT_BY_NAME, SORT_BY_CREATED, SORT_BY_MODIFIED}`
37 + - `direction ∈ {SORT_ASC, SORT_DESC}`
38 + - Default: `(SORT_BY_MODIFIED, SORT_DESC)`
39 +- `ignore`: `.gitignore`-style patterns as inline string, or `file:` reference:
40 + - `file:/abs/path/.gitignore` or `file:///abs/path/.gitignore` → absolute path
41 + - `file:relative/path/.gitignore` or `file://.gitignore` → resolved relative to `relative_path`
42 + - Comments and blanks ignored; support `!` negation, trailing `/` (directory-only), and `**` recursion
43 +- `output_mode`: One of:
44 + - `OUTPUT_MODE_STRING`: Return a single multi-line string
45 + - `OUTPUT_MODE_FLAT`: Return a flat list of TreeItem dicts
46 + - `OUTPUT_MODE_NESTED`: Return a top-level list where folder TreeItems include `items` arrays
47 +
48 +## Return Types
49 +- `OUTPUT_MODE_STRING` → `str` (multi-line ASCII)
50 +- `OUTPUT_MODE_FLAT` → `list[TreeItem]`
51 +- `OUTPUT_MODE_NESTED` → `list[TreeItem]`
52 +
53 +Where `TreeItem` has:
54 +- `name: str`
55 +- `level: int` (root entries start at 1)
56 +- `type: "file" | "folder" | "comment"`
57 +- `created: datetime` (timezone-aware UTC)
58 +- `modified: datetime` (timezone-aware UTC)
59 +- `text: str` (rendered single-line tree segment, e.g., `├── main.py`, `└── helpers/`, `# 6 more folders`)
60 +- `items: list[TreeItem] | None` (folders in nested mode contain children; files/comments are `None` or empty)
61 +
62 +## Behavior
63 +- Traversal order: Breadth-first by depth (FR‑006) for discovery and limit enforcement; string rendering walks the established tree depth-first so connector glyphs reflect parent/child structure.
64 +- Sorting/grouping: Respect `folders_first`; within each group, sort by requested key/direction (FR‑005).
65 +- Limits:
66 + - Per-directory `max_folders` / `max_files` summarize omitted items with comment lines (FR‑007). When only a single entry exceeds the limit and the limit is greater than zero, that entry is rendered instead of emitting a summary comment. A value of `0` is treated as unlimited for each per-directory constraint.
67 + - Global `max_lines` caps the total rendered lines; finish current depth level before stopping further descent.
68 +- ASCII format (FR‑008):
69 + - Use `├──` / `└──` connectors, indentation guides, append `/` to folder names.
70 + - Prefix comment lines with `# ` (e.g., `# 12 more files`).
71 +- Ignore semantics (FR‑004): Use Git wildmatch via `pathspec`, evaluate paths relative to the scanned root; honor negation `!` patterns and directory-only patterns.
72 +
73 +## Error Handling
74 +- Non-existent `relative_path` or inaccessible directories: Raise a clear, actionable error (FR‑009).
75 +
76 +## Datetime Conversions (docstring examples required)
77 +```python
78 +# item['created'] and item['modified'] are timezone-aware UTC datetime objects.
79 +iso = item['created'].isoformat() # e.g., '2025-11-09T00:21:00.123456+00:00'
80 +epoch = item['created'].timestamp() # e.g., 1762647660.123456
81 +```
82 +
83 +## Docstring Requirements
84 +- Document every parameter, including the predefined constants (`SORT_BY_*`, `SORT_*`, `OUTPUT_MODE_*`) so callers understand valid values.
85 +- Include code snippets demonstrating conversion of `created`/`modified` datetimes to ISO 8601 strings and Unix timestamps (see Datetime Conversions section).
86 +- Explain `ignore` resolution for inline patterns and `file:`/`file://`/`file:///` references with examples that match FR‑003 semantics.
87 +- Note that the utility is synchronous and should not be invoked on hot async event-loop paths.
88 +
89 +## Helper Functions (module-internal)
90 +- Implement helpers at end of module with single leading underscore:
91 + - `_resolve_ignore_patterns(...)`
92 + - `_list_directory_children(...)`
93 + - `_apply_sorting_and_limits(...)`
94 + - `_format_line(...)`
95 + - `_build_tree_items_flat(...)`
96 + - `_to_nested_structure(...)`
97 +
98 +## Implementation Notes
99 +- Perform filesystem operations via existing Agent Zero helpers in `python/helpers/files.py` (`get_abs_path`, `exists`, `list_files`, timestamp helpers, etc.); fall back to standard library only when no wrapper exists.
100 +- Keep helper definitions module-internal (single leading underscore) to preserve the public API surface.
101 +
102 +## Dependencies
103 +- `pathspec==0.12.1` for Git wildmatch semantics (FR‑004/Dependencies).
specs/001-file-tree-utility/data-model.md new
+49
@@ -0,0 +1,49 @@
1 +# Data Model: File Tree Utility
2 +
3 +## Entity: TreeItem
4 +
5 +- `name: str` — filename or folder name (or comment label)
6 +- `level: int` — depth level starting from 1 at root entries
7 +- `type: \"file\" | \"folder\" | \"comment\"`
8 +- `created: datetime` — timezone-aware UTC
9 +- `modified: datetime` — timezone-aware UTC
10 +- `text: str` — one-line rendered tree segment (e.g., `├── main.py`, `└── helpers/`, `# 6 more folders`)
11 +- `items: list[TreeItem] | null` — present for folders in nested mode; empty or null for files and comments
12 +
13 +### Example (flat item)
14 +```json
15 +{
16 + "name": "context_data.py",
17 + "level": 1,
18 + "type": "file",
19 + "created": "datetime (UTC-aware)",
20 + "modified": "datetime (UTC-aware)",
21 + "text": "├── context_data.py",
22 + "items": null
23 +}
24 +```
25 +
26 +### Example (nested folder)
27 +```json
28 +{
29 + "name": "helpers",
30 + "level": 1,
31 + "type": "folder",
32 + "created": "datetime (UTC-aware)",
33 + "modified": "datetime (UTC-aware)",
34 + "text": "└── helpers/",
35 + "items": [
36 + {
37 + "name": "api.py",
38 + "level": 2,
39 + "type": "file",
40 + "created": "datetime (UTC-aware)",
41 + "modified": "datetime (UTC-aware)",
42 + "text": " ├── api.py",
43 + "items": null
44 + }
45 + ]
46 +}
47 +```
48 +
49 +
specs/001-file-tree-utility/plan.md new
+83
@@ -0,0 +1,83 @@
1 +# Implementation Plan: File Tree Utility
2 +
3 +**Branch**: `001-file-tree-utility` | **Date**: 2025-11-08 | **Spec**: `specs/001-file-tree-utility/spec.md`
4 +**Input**: Feature specification from `/specs/001-file-tree-utility/spec.md`
5 +
6 +**Note**: This plan is produced by the `/speckit.plan` workflow.
7 +
8 +## Summary
9 +
10 +Implement a `file_tree()` utility in `python/helpers/files.py` that generates a readable ASCII tree along with structured metadata. The function supports three output modes (`string`, `flat`, `nested`), breadth‑first traversal, `.gitignore`-style filtering via `pathspec`, sorting and grouping (folders vs files), depth and line limits, and summary comment lines when limits are reached. All filesystem access must use existing helpers in `python/helpers/files.py`. Convenience constants are defined adjacent to the function, and helper routines are implemented as module-internal functions with a single leading underscore.
11 +
12 +## Technical Context
13 +
14 +**Language/Version**: Python 3.11+
15 +**Primary Dependencies**: `pathspec==0.12.1`, standard library `datetime`, existing helpers in `python/helpers/files.py`
16 +**Storage**: N/A (read-only filesystem traversal)
17 +**Testing**: `pytest` with temporary directories (mktemp family) and snapshot/assertion tests under `tests/`
18 +**Target Platform**: Linux (development + container), cross-platform filesystem semantics respected
19 +**Project Type**: Single-user backend helper within existing codebase
20 +**Performance Goals**: For ≥5,000 entries and `max_lines=200`, return within ~2 seconds with deterministic ordering (per SC-003)
21 +**Constraints**:
22 +- Use Agent Zero filesystem helpers (FR‑013) for path ops and listing
23 +- Honor `.gitignore` Git wildmatch semantics including negation and `**` (FR‑004)
24 +- Breadth‑first traversal order; folders grouped per `folders_first` (FR‑005/006)
25 +- Non-blocking async discipline: function is synchronous; avoid calling on event loop hot paths
26 +**Scale/Scope**: Should handle large directories (5k+ entries) with limits applied per directory and overall lines
27 +
28 +## Constitution Check
29 +
30 +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
31 +
32 +- Exploration-First Development: Using existing helpers in `python/helpers/files.py` and reading spec; PASS
33 +- Security-First: Read-only traversal, no secrets to UI, no new endpoints; PASS
34 +- Non-Blocking Async: Utility is synchronous; caller must not invoke on event loop hot paths; PASS
35 +- Architectural Boundaries: No new systems; extends helpers only; PASS
36 +- Environment Separation: Reuses helpers that abstract environment; PASS
37 +- PrintStyle Logging Only: No logging added; N/A
38 +- Project Scope & Simplicity: Single-user helper, no enterprise features; PASS
39 +
40 +## Project Structure
41 +
42 +### Documentation (this feature)
43 +```text
44 +specs/001-file-tree-utility/
45 +├── plan.md # This file (/speckit.plan output)
46 +├── research.md # Phase 0 output (/speckit.plan)
47 +├── data-model.md # Phase 1 output (/speckit.plan)
48 +├── quickstart.md # Phase 1 output (/speckit.plan)
49 +└── contracts/ # Phase 1 output (/speckit.plan)
50 +```
51 +
52 +### Source Code (repository root)
53 +```text
54 +python/
55 +└── helpers/
56 + └── files.py # Add file_tree(), constants, and helper functions
57 +
58 +tests/
59 +├── test_file_tree_string.py
60 +├── test_file_tree_structured.py
61 +└── test_file_tree_ignore_and_limits.py
62 +```
63 +
64 +**Structure Decision**: Extend `python/helpers/files.py` with the new utility and add three pytest modules in `tests/` to validate string snapshot, structured outputs, ignore/limits, and sorting behaviors.
65 +
66 +- `tests/test_file_tree_string.py` also owns the breadth-first traversal regression test to ensure level N entries render before level N+1 across modes.
67 +- `tests/test_file_tree_structured.py` covers metadata assertions for flat/nested outputs, including ignore semantics for nested structures.
68 +- `tests/test_file_tree_ignore_and_limits.py` includes the performance guard using `time.perf_counter()` to assert the ≤2.0 s budget on ≥5k synthetic entries while exercising limit summaries.
69 +
70 +## Complexity Tracking
71 +
72 +No constitutional violations identified; section not applicable.
73 +
74 +## Constitution Check (Post-Design)
75 +
76 +Re-evaluated after drafting research, data model, contracts, and quickstart:
77 +- Exploration-First Development: PASS
78 +- Security-First Design: PASS
79 +- Non-Blocking Async: PASS (utility remains synchronous; callers should avoid hot event-loop contexts)
80 +- Architectural Boundaries: PASS
81 +- Environment Separation: PASS
82 +- PrintStyle Logging Only: PASS (no logging added)
83 +- Project Scope & Simplicity: PASS
specs/001-file-tree-utility/quickstart.md new
+101
@@ -0,0 +1,101 @@
1 +# Quickstart: File Tree Utility
2 +
3 +## Overview
4 +`file_tree()` in `python/helpers/files.py` renders a directory tree as:
5 +- Multi-line ASCII string (`OUTPUT_MODE_STRING`)
6 +- Flat list of items with metadata (`OUTPUT_MODE_FLAT`)
7 +- Nested list where folders contain `items` arrays (`OUTPUT_MODE_NESTED`)
8 +
9 +## Import
10 +```python
11 +from python.helpers.files import (
12 + file_tree,
13 + SORT_BY_NAME, SORT_BY_CREATED, SORT_BY_MODIFIED,
14 + SORT_ASC, SORT_DESC,
15 + OUTPUT_MODE_STRING, OUTPUT_MODE_FLAT, OUTPUT_MODE_NESTED,
16 +)
17 +```
18 +
19 +## Examples
20 +
21 +### 1) String mode (ASCII)
22 +```python
23 +tree_str = file_tree(
24 + relative_path="my_project",
25 + output_mode=OUTPUT_MODE_STRING,
26 + max_depth=0,
27 + max_lines=0,
28 + folders_first=True,
29 + sort=(SORT_BY_MODIFIED, SORT_DESC),
30 +)
31 +print(tree_str)
32 +```
33 +Sample output:
34 +```text
35 +my_project/
36 +├── context_data.py
37 +├── main.py
38 +└── helpers/
39 + ├── api.py
40 + └── utils/
41 + ├── files.py
42 + └── strings.py
43 +```
44 +
45 +### 2) Flat mode (structured list)
46 +```python
47 +items = file_tree(
48 + relative_path="my_project",
49 + output_mode=OUTPUT_MODE_FLAT,
50 + max_depth=2,
51 + max_lines=200,
52 + folders_first=True,
53 + sort=(SORT_BY_NAME, SORT_ASC),
54 +)
55 +
56 +# Convert datetimes:
57 +first = items[0]
58 +iso_created = first["created"].isoformat()
59 +ts_created = first["created"].timestamp()
60 +```
61 +
62 +### 3) Nested mode (hierarchical)
63 +```python
64 +nested = file_tree(
65 + relative_path="my_project",
66 + output_mode=OUTPUT_MODE_NESTED,
67 + max_depth=2,
68 + folders_first=True,
69 +)
70 +```
71 +
72 +## Ignore Patterns
73 +Inline patterns:
74 +```python
75 +ignore = \"\"\"\n*.pyc\n__pycache__/\n!important_file.py\n\"\"\"\n
76 +items = file_tree(\"my_project\", output_mode=OUTPUT_MODE_FLAT, ignore=ignore)
77 +```
78 +
79 +From file (resolved as specified):
80 +```python
81 +# Absolute path
82 +items = file_tree(\"my_project\", output_mode=OUTPUT_MODE_FLAT, ignore=\"file:/abs/path/.gitignore\")
83 +# Absolute (URI form)
84 +items = file_tree(\"my_project\", output_mode=OUTPUT_MODE_FLAT, ignore=\"file:///abs/path/.gitignore\")
85 +# Relative to scan root
86 +items = file_tree(\"my_project\", output_mode=OUTPUT_MODE_FLAT, ignore=\"file:.gitignore\")
87 +items = file_tree(\"my_project\", output_mode=OUTPUT_MODE_FLAT, ignore=\"file://.gitignore\")
88 +```
89 +
90 +## Limits and Summaries
91 +```python
92 +tree_str = file_tree(
93 + relative_path=\"large_dir\",
94 + output_mode=OUTPUT_MODE_STRING,
95 + max_lines=200,
96 + max_folders=10,
97 + max_files=20,
98 +)
99 +```
100 +When limits are hit within a directory, summary comment lines are emitted, e.g.:
101 +```\n# 12 more files\n# 6 more folders\n```
specs/001-file-tree-utility/research.md new
+37
@@ -0,0 +1,37 @@
1 +# Research: File Tree Utility
2 +
3 +## Decisions
4 +
5 +- Ignore semantics implementation: Use `pathspec` with Git wildmatch patterns to evaluate `.gitignore`-style rules, including `!` negation, directory-only patterns (trailing `/`), and `**` recursion. Paths must be evaluated relative to the scanned root.
6 +- `ignore` input resolution:
7 + - Inline patterns: treat the string as `.gitignore` content
8 + - `file:/abs/path/.gitignore` and `file:///abs/path/.gitignore`: absolute file path
9 + - `file:relative/path/.gitignore` and `file://.gitignore`: resolve relative to `relative_path` (the scan root)
10 +- Traversal strategy: Breadth-first by depth. For each directory, collect children (folders/files), apply limits (`max_folders`, `max_files`) and sorting, render lines and comments, then move to next directory at the same depth before descending.
11 +- Sorting: Group by folder/file depending on `folders_first` (default True). Within each group, sort by `modified` (default key) descending (default direction). Support keys: name, created, modified; directions: asc, desc.
12 +- Performance: Cap items per directory, cap total lines (`max_lines`), and short-circuit deeper traversal once `max_lines` is met after finishing the current depth pass. Ensure deterministic ordering with stable sorts.
13 +- Datetime fields: Use timezone-aware UTC `datetime` for `created` and `modified`. Consumers can convert with `.isoformat()` or `.timestamp()` as needed.
14 +- Output forms:
15 + - `string`: join rendered `text` lines with newlines (no metadata returned)
16 + - `flat`: return flat list of TreeItem dicts
17 + - `nested`: return top-level list where folder TreeItems contain `items` arrays of children; files/comments have `items` as `null` or `[]`
18 +- Helper placement: Implement helpers at end of `python/helpers/files.py` with single leading underscore names (module-internal convention).
19 +
20 +## Rationale
21 +
22 +- `pathspec` is de facto for Git wildmatch semantics; it correctly handles edge cases and negations.
23 +- Breadth-first traversal matches spec requirements (FR‑006) and yields predictable depth-wise rendering.
24 +- Using existing helpers in `python/helpers/files.py` adheres to architecture and centralizes filesystem logic.
25 +- UTC-aware datetimes avoid ambiguity and make conversions straightforward.
26 +
27 +## Alternatives Considered
28 +
29 +- `gitignore_parser`: Simpler API but less explicit control vs `pathspec` and not the preferred dependency in spec.
30 +- Depth-first traversal: Simpler implementation, but violates FR‑006 breadth-first requirement and produces different visual grouping.
31 +- Returning only strings: Loses metadata needed by downstream automation; rejected in favor of multiple modes.
32 +
33 +## Clarifications Resolved
34 +
35 +- Python version: Target Python 3.11+ (tested under 3.12 on Linux). No features require newer than 3.11.
36 +- Default `folders_first`: True by default (per Clarifications), with parameter to toggle.
37 +- Large directory behavior: Per-directory `max_folders`/`max_files` with summary comment lines; stop descending when `max_lines` reached, but finish current depth scan.
specs/001-file-tree-utility/review.md new
+193
@@ -0,0 +1,193 @@
1 +# File Tree Utility – Formal Review & Remediation Plan
2 +
3 +## 1. Scope & Context
4 +- **Feature**: `file_tree()` helper and supporting internals in `python/helpers/files.py`
5 +- **Artifacts Reviewed**:
6 + - Implementation source (`python/helpers/files.py`)
7 + - Test modules (`tests/test_file_tree_string.py`, `tests/test_file_tree_structured.py`, `tests/test_file_tree_ignore_and_limits.py`, `tests/test_file_tree_invalid.py`)
8 + - String fixtures under `tests/fixtures/file_tree/`
9 + - Specification bundle in `specs/001-file-tree-utility/` (spec, plan, data-model, contracts, research, tasks)
10 + - Constitution (`.specify/memory/constitution.md`)
11 +- **Environment**: Python 3.12.11 inside project `.venv`, pytest 8.4.2
12 + `PYTHONPATH=. pytest tests/test_file_tree_string.py tests/test_file_tree_structured.py tests/test_file_tree_ignore_and_limits.py tests/test_file_tree_invalid.py`
13 +
14 +## 2. Specification References
15 +- **FR‑006** / **FR‑007** / **FR‑008** – `specs/001-file-tree-utility/spec.md`
16 +- `contracts/file_tree.md` – finish-current-depth requirement, summary semantics
17 +- Tasks `T013`, `T030`, `T031` – enforcement of depth-finish, limit summaries
18 +- Constitution Principles I & VII – verification of contractual behaviour
19 +
20 +## 3. Verified Behaviour & Reproduction
21 +```
22 +python - <<'PY'
23 +from python.helpers.files import file_tree, create_dir, delete_dir, write_file, get_abs_path
24 +import os
25 +
26 +def materialize(base, tree):
27 + for name, value in tree.items():
28 + rel = os.path.join(base, name)
29 + if isinstance(value, dict):
30 + create_dir(rel)
31 + materialize(rel, value)
32 + else:
33 + write_file(rel, value or "")
34 +
35 +base = "tmp/tests/file_tree/string_breadth_first_check"
36 +delete_dir(base); create_dir(base)
37 +materialize(base, {
38 + "alpha": {"alpha_file.txt": "alpha", "nested": {"inner.txt": "inner"}},
39 + "beta": {"beta_file.txt": "beta"},
40 + "zeta": {},
41 + "a.txt": "A",
42 + "b.txt": "B",
43 +})
44 +print(file_tree(base, folders_first=True, sort=("name","asc"), output_mode="string"))
45 +delete_dir(base)
46 +PY
47 +```
48 +Output shows stray leading `│` ahead of deeper nodes (`nested/`, `alpha_file.txt`, etc.), despite all level-one siblings already rendered.
49 +
50 +```
51 +python - <<'PY'
52 +from python.helpers.files import file_tree, create_dir, delete_dir, write_file
53 +import os
54 +
55 +def materialize(base, tree):
56 + for name, value in tree.items():
57 + rel = os.path.join(base, name)
58 + if isinstance(value, dict):
59 + create_dir(rel)
60 + materialize(rel, value)
61 + else:
62 + write_file(rel, value or "")
63 +
64 +base = "tmp/tests/file_tree/string_ignore_limits_check"
65 +delete_dir(base); create_dir(base)
66 +materialize(base, {
67 + "src": {
68 + "main.py": "print('hello')",
69 + "utils.py": "pass",
70 + "tmp.tmp": "",
71 + "cache": {"cached.txt": "", "keep.txt": ""},
72 + "modules": {"a.py": "", "b.py": "", "c.py": ""},
73 + },
74 + "logs": {"2024.log": "", "2025.log": ""},
75 + "notes.md": "",
76 + "build.tmp": "",
77 +})
78 +write_file(os.path.join(base, ".treeignore"), "\n".join(
79 + ["*.tmp", "cache/", "!src/cache/keep.txt", "logs/", "!logs/2025.log"]
80 +))
81 +print(file_tree(
82 + base,
83 + folders_first=False,
84 + sort=("name","asc"),
85 + ignore="file:.treeignore",
86 + max_folders=1,
87 + max_files=2,
88 + max_lines=12,
89 + output_mode="string",
90 +))
91 +delete_dir(base)
92 +PY
93 +```
94 +Produces:
95 +```
96 +tmp/tests/file_tree/string_ignore_limits_check/
97 +├── .treeignore
98 +├── notes.md
99 +├── logs/
100 +└── # 1 more folders
101 +│ └── 2025.log
102 +```
103 +- Comment precedes folder child (`2025.log`), implying a “comment node” with children.
104 +- Grammar shows “# 1 more folders” (singular noun mismatch).
105 +
106 +```
107 +python - <<'PY'
108 +from python.helpers.files import file_tree, create_dir, delete_dir, write_file
109 +import os
110 +
111 +def materialize(base, tree):
112 + for name, value in tree.items():
113 + rel = os.path.join(base, name)
114 + if isinstance(value, dict):
115 + create_dir(rel)
116 + materialize(rel, value)
117 + else:
118 + write_file(rel, value or "")
119 +
120 +base = "tmp/tests/file_tree/string_max_lines_check"
121 +delete_dir(base); create_dir(base)
122 +materialize(base, {
123 + "dirA": {f"a{i}.txt": "" for i in range(3)},
124 + "dirB": {f"b{i}.txt": "" for i in range(3)},
125 + "root.txt": "",
126 +})
127 +print(file_tree(base, max_lines=2, output_mode="string"))
128 +print(file_tree(base, max_lines=2, output_mode="flat"))
129 +delete_dir(base)
130 +PY
131 +```
132 +Only two top-level entries appear, violating the “finish current depth before stopping” contract.
133 +
134 +Pytest run (with corrected `PYTHONPATH`) currently passes, proving tests fail to cover these cases:
135 +```
136 +PYTHONPATH=. pytest tests/test_file_tree_string.py tests/test_file_tree_structured.py \
137 + tests/test_file_tree_ignore_and_limits.py tests/test_file_tree_invalid.py
138 +```
139 +
140 +## 4. Findings (F1–F5)
141 +
142 +| ID | Description | Root Cause | Impacted Spec Items |
143 +|----|-------------|------------|---------------------|
144 +| **F1** | ASCII tree shows stray leading `│` for deeper nodes; visual connectors imply depth-first emission though actual order is breadth-first. | String renderer consumes breadth-first list (`nodes_in_order`) directly; `_format_line()` computes connector state from ancestors whose `is_last` flags were never recalculated for depth-first rendering. | FR‑006, FR‑008, Contract §Behavior (connector expectations) |
145 +| **F2** | Summary comments appear before descendants, giving “comment with children.” | `_apply_sorting_and_limits()` appends `_create_summary_comment()` to children list feeding BFS queue. Comments emitted before queue dequeues the folder they summarize. | FR‑007, Contract §Behavior |
146 +| **F3** | Singular omission reported as `# 1 more folders`; UX expects either singular noun or direct rendering of the lone item. | `name=f"{count} more {noun}"` uses plural noun regardless of count; limit logic never promotes single remaining entries. | FR‑007 (per-directory summaries) |
147 +| **F4** | `max_lines` stops mid-level, omitting siblings at same depth. | String/flat outputs slice `nodes_in_order[:limit]`; `limit_level` is ignored when rendering string/flat outputs. | FR‑007, Contract §Behavior (“finish current depth before stopping”) |
148 +| **F5** | Existing tests/fixtures miss defects above. | Test suite lacks low `max_lines`, connector validation, summary ordering, and singular cases. | Tasks T030, T031; SC-criteria in spec |
149 +
150 +## 5. Remediation Plan
151 +
152 +### 5.1 Renderer & Traversal
153 +1. Retain breadth-first traversal for limit computation but render ASCII via a depth-first serializer that walks the established `_TreeEntry` hierarchy recursively, calculating connectors in-context.
154 +2. Recompute/propagate `is_last` flags inside the depth-first renderer (children sorted by final render order).
155 +
156 +### 5.2 Summary Comment Semantics
157 +1. Store summary counts as metadata rather than queue entries, or render them after child subtrees (e.g., track pending comments and emit once child recursion finishes).
158 +2. Handle singular counts explicitly:
159 + - If only one entry exceeds the per-directory limit, render the entry (preferred) or output `# 1 more folder`.
160 + - Ensure comments never accumulate children; `items` stays `None`.
161 +
162 +### 5.3 `max_lines` Compliance
163 +1. Use `limit_level` to cap traversal depth instead of slicing output arrays:
164 + - Build nodes breadth-first as today.
165 + - Before rendering, prune the tree using `limit_level` so entire depths remain intact.
166 + - For flat outputs, filter nodes by `level <= limit_level` (plus any summaries if retained).
167 +
168 +### 5.4 Documentation Updates
169 +1. Update `file_tree()` docstring with corrected examples (showing depth-first ASCII and revised summary semantics).
170 +2. If singular behaviour shifts to “render entry” rather than comment, reflect in `contracts/file_tree.md`.
171 +
172 +### 5.5 Testing Enhancements
173 +Add targeted cases:
174 +1. `test_file_tree_string_depth_render()` – verifies ASCII connectors with multi-level tree (no stray leading `│`).
175 +2. `test_file_tree_summary_comment_order()` – ensures comments render after child listings and never own children.
176 +3. `test_file_tree_summary_comment_singular()` – confirms either singular grammar or rendering of the lone item.
177 +4. `test_file_tree_max_lines_depth_finish()` – asserts string/flat/nested modes honour level completion when `max_lines` < number of level-one entries.
178 +
179 +## 6. Post-Fix Verification Checklist
180 +- Run enhanced pytest suite (`PYTHONPATH=. pytest ...`) ensuring new tests cover regressions.
181 +- Manual spot-check using reproduction scripts above.
182 +- Validate docstring/contract snippets compile and align with outputs.
183 +- Confirm summary comments contain zero children and appear only after their folder’s listings.
184 +- Verify singular grammar or item rendering meets UX decision.
185 +
186 +## 7. Handoff Notes
187 +- No schema changes required; `_TreeEntry` can remain dataclass.
188 +- Refactor should avoid altering traversal ordering semantics beyond rendering & limit handling.
189 +- Communicate final summary-comment decisions back into spec to keep documentation authoritative.
190 +
191 +---
192 +
193 +Prepared for follow-up implementation; provides reproduction commands, root causes, and actionable remediation to eliminate current defects without further clarification.
specs/001-file-tree-utility/spec.md new
+152
@@ -0,0 +1,152 @@
1 +# Feature Specification: File Tree Utility
2 +
3 +**Feature Branch**: `001-file-tree-utility`
4 +**Created**: 2025-11-08
5 +**Status**: Draft
6 +**Input**: User description: "File Tree Utility"
7 +
8 +## User Scenarios & Testing *(mandatory)*
9 +
10 +<!--
11 + IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
12 + Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
13 + you should still have a viable MVP (Minimum Viable Product) that delivers value.
14 +
15 + Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
16 + Think of each story as a standalone slice of functionality that can be:
17 + - Developed independently
18 + - Tested independently
19 + - Deployed independently
20 + - Demonstrated to users independently
21 +-->
22 +
23 +### User Story 1 - Readable Tree Overview (Priority: P1)
24 +
25 +As a developer, I want a fast, readable ASCII tree of a folder so I can quickly understand the structure without opening each directory.
26 +
27 +**Why this priority**: Immediate visualization of project layout is the most common need and unlocks rapid navigation and understanding, within the SC-003 performance target (~2 seconds for ≥5,000 entries with `max_lines=200`).
28 +
29 +**Independent Test**: Create a synthetic directory structure and call the utility with `mode=string`; verify the exact multi-line ASCII output matches the expected snapshot.
30 +
31 +**Acceptance Scenarios**:
32 +
33 +1. **Given** a base folder with subfolders and files, **When** I call the utility with default parameters and `mode=string`, **Then** it returns a multi-line ASCII tree with folders suffixed by `/`, and summary comment lines for truncated lists using the format `# N more files|folders`.
34 +2. **Given** an ignore pattern string using `.gitignore` syntax, **When** I call the utility, **Then** ignored entries are excluded while negation patterns re-include matching paths.
35 +3. **Given** nested folders at three or more depth levels, **When** I call the utility with `mode=string`, **Then** every level N renders before any entry from level N+1 (breadth-first ordering).
36 +
37 +---
38 +
39 +### User Story 2 - Flat Structured Listing (Priority: P2)
40 +
41 +As a developer, I want a flat list of items with metadata (name, type, level, created/modified datetimes, text) so I can programmatically filter, analyze, or render custom outputs.
42 +
43 +**Why this priority**: Enables downstream automation and analysis beyond visual inspection.
44 +
45 +**Independent Test**: Call the utility with `mode=flat` and verify the array of objects contains correct fields and values for a known synthetic structure.
46 +
47 +**Acceptance Scenarios**:
48 +
49 +1. **Given** a known directory with deterministic file timestamps, **When** I call `mode=flat`, **Then** each item includes `name`, `level`, `type ∈ {file, folder, comment}`, `created` (tz-aware UTC), `modified` (tz-aware UTC), and `text`, and ordering respects sort rules.
50 +
51 +---
52 +
53 +### User Story 3 - Nested Structured Listing (Priority: P3)
54 +
55 +As a developer, I want a nested structure where folder items contain an `items` array of their children so I can traverse the tree in-memory.
56 +
57 +**Why this priority**: Supports hierarchical processing and incremental rendering.
58 +
59 +**Independent Test**: Call the utility with `mode=nested` and verify parent folders contain `items` arrays while files and comments have `items` as `null` or `[]`.
60 +
61 +**Acceptance Scenarios**:
62 +
63 +1. **Given** a directory with multiple depths, **When** I call `mode=nested` with `max_depth=2`, **Then** the result contains children up to level 2 and deeper levels are represented by comment lines indicating remaining items.
64 +
65 +---
66 +
67 +[Add more user stories as needed, each with an assigned priority]
68 +
69 +### Edge Cases
70 +
71 +- Empty directories should produce only the top-level line and no errors.
72 +- `max_depth=0` means unlimited depth; `max_lines=0` means unlimited lines.
73 +- `folders_first` ordering toggles whether folders are listed before files; sorting still applies within each group.
74 +- Very large directories apply `max_folders`/`max_files` per directory and append comment lines (e.g., `# 23 more files`).
75 +- Ignore patterns must follow `.gitignore` semantics including `!` negation, directory-only patterns (`/`), and `**` recursion.
76 +- Non-existent path results in a clear error surfaced to the caller.
77 +
78 +## Requirements *(mandatory)*
79 +
80 +### Functional Requirements
81 +
82 +- **FR-001**: Provide a folder tree utility that outputs one of three modes:
83 + a) `string`: multi-line ASCII tree;
84 + b) `flat`: flat array of items with metadata;
85 + c) `nested`: array at root where folder items include `items` arrays for children.
86 +- **FR-002**: Each listed element must include: `name`, `level` (depth starting at 1 for root entries), `type` (`file` | `folder` | `comment`), `created` (timezone-aware UTC datetime), `modified` (timezone-aware UTC datetime), and `text` (the rendered single-line tree segment such as `├── file.py` or `└── # 6 more folders`).
87 +- **FR-003**: Accept parameters: `relative_path`, `max_depth` (0 = unlimited), `max_lines` (0 = unlimited), `folders_first` (bool), `max_folders`, `max_files`, `sort` (key: name | modified | created; dir: asc | desc), `ignore` (string with `.gitignore` semantics; if starts with `file:`, load patterns from that file), and `output_mode` (`string` | `flat` | `nested`).
88 + - `ignore` resolution rules when using `file:`:
89 + - `file:/abs/path/.gitignore` → absolute filesystem path (single slash after colon indicates absolute path)
90 + - `file:relative/path/.gitignore` → path resolved relative to `relative_path` (scanned root)
91 + - `file://.gitignore` → path resolved relative to `relative_path` (two slashes indicate URI form; still relative without the third slash)
92 + - `file:///abs/path/.gitignore` → absolute filesystem path (classic URI absolute form with three slashes)
93 + - If not prefixed with `file:`, treat `ignore` as inline `.gitignore` content string
94 +- **FR-004**: Apply ignore patterns using Git wildmatch semantics (comments, blanks ignored; support `!` negation, directory-only patterns, `**` recursion) against paths relative to the scanned root.
95 +- **FR-005**: Sorting: group by folder/file based on `folders_first`, then sort by the requested key and direction within each group. Default is sort by modification time descending; default `folders_first=True` (folders before files).
96 +- **FR-006**: Traversal order is breadth-first by depth: scan and render all directories at level N before descending to N+1, honoring `max_depth`, `max_files`, and `max_folders`.
97 +- **FR-007**: When limits are reached within a directory, render summary comment lines indicating the number of omitted files and/or folders (e.g., `# 12 more files`, `# 6 more folders`).
98 +- **FR-008**: The ASCII `text` format uses `├──`/`└──` with indentation guides, appends `/` to folders, and prefixes comments with `# `.
99 +- **FR-009**: Errors for invalid inputs (e.g., non-existent path) must be clear and actionable.
100 +- **FR-010**: Implementation MUST satisfy the detailed interface contract documented in `specs/001-file-tree-utility/contracts/file_tree.md`, including developer-facing ergonomics, helper structure, and docstring guidance.
101 +
102 +## Clarifications
103 +
104 +### Session 2025-11-08
105 +
106 +- Q: What should be the default for `folders_first` ordering? → A: `True` (folders before files) by default.
107 +- Q: Which filesystem access conventions should be followed? → A: Use Agent Zero wrappers in `python/helpers/files.py` (e.g., `get_abs_path`, `exists`, `list_files`) whenever possible; fallback to the same Python modules used in `files.py` only when no wrapper exists.
108 +- Q: How to resolve `ignore` when prefixed with `file:`? → A: Resolve relative to `relative_path` when the path after `file:` or `file://` is relative; treat as absolute when starting with `/` (support both `file:/abs/...` and `file:///abs/...`). Document with examples in the function docstring.
109 +
110 +### Key Entities *(include if feature involves data)*
111 +
112 +- **TreeItem**:
113 + - `name: str` — filename or folder name (or comment label)
114 + - `level: int` — depth level starting from 1 at root entries
115 + - `type: "file" | "folder" | "comment"`
116 + - `created: datetime` — timezone-aware UTC
117 + - `modified: datetime` — timezone-aware UTC
118 + - `text: str` — one-line rendered tree segment
119 + - `items: list[TreeItem] | null` — present for folders in nested mode; empty or null for files and comments
120 +
121 +## Success Criteria *(mandatory)*
122 +
123 +### Measurable Outcomes
124 +
125 +- **SC-001**: For a known synthetic structure (≤ 50 nodes), `mode=string` output matches the stored snapshot exactly (whitespace and glyphs).
126 +- **SC-002**: Given ignore patterns with negations, excluded entries never appear and negated entries always appear in all modes.
127 +- **SC-003**: For a directory containing ≥ 5,000 entries, an automated test using a monotonic timer (`time.perf_counter()`) asserts that a call with `max_lines=200` completes within 2.0 seconds while rendering deterministic ordering and correct summary comment lines.
128 +- **SC-004**: `created`/`modified` fields are timezone-aware UTC datetimes and can be converted to ISO 8601 strings and Unix timestamps without loss of timezone information in all modes that include metadata.
129 +- **SC-005**: Automated tests create temporary top-level directories using the mktemp family (cross‑platform), generate synthetic structures, and validate:
130 + - String snapshot equality for `mode=string`
131 + - Structural and field correctness for `mode=flat` and `mode=nested`
132 + - Ignore semantics including negation and directory-only patterns in all modes (string, flat, nested)
133 + - Breadth-first ordering across multiple depth levels (fails if depth-first traversal occurs)
134 + - Sorting and summary comment behaviors under limits
135 + - Sorting for `name`, `created`, and `modified` keys in both ascending and descending directions across flat and nested outputs
136 + - `created`/`modified` metadata returned by flat and nested modes matches the filesystem stats for the represented entries
137 + - Invalid parameter paths (unsupported sort tuples, unknown output modes, negative limits, missing referenced ignore file) raise the documented exceptions
138 + Tests reside under `tests/` and use the utility from `python/helpers/files.py`.
139 +
140 +## Testing
141 +
142 +- Tests MUST construct synthetic folder/file structures in temporary directories using mktemp family functions to ensure cross‑platform behavior.
143 +- Tests MUST import and exercise `file_tree()` from `python/helpers/files.py` (no duplicated logic).
144 +- Recommended organization:
145 + - `tests/test_file_tree_string.py` — end‑to‑end snapshot tests for `mode=string`
146 + - `tests/test_file_tree_structured.py` — validations for `mode=flat` and `mode=nested` (fields, levels, types, ordering)
147 + - `tests/test_file_tree_ignore_and_limits.py` — `.gitignore` semantics, `max_files`/`max_folders`, `max_depth`, `max_lines`
148 +- For snapshots, store expected multi‑line strings as literals within tests or fixture files adjacent to tests; ensure glyphs (`├──`, `└──`) and folder suffix `/` and comment prefix `# ` are preserved.
149 +
150 +## Dependencies
151 +
152 +- `.gitignore` evaluation MUST use `pathspec` (Git wildmatch semantics). Target version: `pathspec==0.12.1` (or newer compatible) for implementation; exact pin may be set during dependency management.
specs/001-file-tree-utility/tasks.md new
+92
@@ -0,0 +1,92 @@
1 +# Tasks: File Tree Utility (`001-file-tree-utility`)
2 +
3 +This checklist is organized by phases and user stories. Each task is atomic, ordered, and includes explicit file paths. Tests are included because the feature specification mandates them.
4 +
5 +## Phase 1 — Setup
6 +
7 +- [X] T001 [P] Verify `pathspec==0.12.1` present in `requirements.txt` (/home/rafael/Workspace/Repos/rafael/a0-local/requirements.txt)
8 +- [X] T002 [P] Create pytest module scaffold for string mode tests (create file) (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_string.py)
9 +- [X] T003 [P] Create pytest module scaffold for structured modes tests (create file) (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_structured.py)
10 +- [X] T004 [P] Create pytest module scaffold for ignore/limits tests (create file) (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_ignore_and_limits.py)
11 +
12 +## Phase 2 — Foundational
13 +
14 +- [X] T005 Add required imports for file tree utility (datetime, typing, pathspec usage; reuse existing helpers) (/home/rafael/Workspace/Repos/rafael/a0-local/python/helpers/files.py)
15 +- [X] T006 Define constants `SORT_BY_NAME`, `SORT_BY_CREATED`, `SORT_BY_MODIFIED`, `SORT_ASC`, `SORT_DESC`, `OUTPUT_MODE_STRING`, `OUTPUT_MODE_FLAT`, `OUTPUT_MODE_NESTED` directly above `file_tree()` (/home/rafael/Workspace/Repos/rafael/a0-local/python/helpers/files.py)
16 +- [X] T007 Add `file_tree()` signature and comprehensive docstring per contract (parameters, constants, ignore resolution examples, datetime conversion examples, async usage note) (/home/rafael/Workspace/Repos/rafael/a0-local/python/helpers/files.py)
17 +- [X] T008 Stub internal helpers (`_resolve_ignore_patterns`, `_list_directory_children`, `_apply_sorting_and_limits`, `_format_line`, `_build_tree_items_flat`, `_to_nested_structure`) at end of module (/home/rafael/Workspace/Repos/rafael/a0-local/python/helpers/files.py)
18 +
19 +## Phase 3 — [US1] Readable Tree Overview (P1)
20 +
21 +- [X] T009 [US1] Implement breadth‑first traversal and directory scanning pipeline honoring `max_depth` (/home/rafael/Workspace/Repos/rafael/a0-local/python/helpers/files.py)
22 +- [X] T010 [US1] Implement `.gitignore` wildmatch filtering via `pathspec`; support `file:` resolution variants (/home/rafael/Workspace/Repos/rafael/a0-local/python/helpers/files.py)
23 +- [X] T011 [US1] Implement grouping/sorting with `folders_first` and `(key, direction)`; default `(modified, desc)` (/home/rafael/Workspace/Repos/rafael/a0-local/python/helpers/files.py)
24 +- [X] T012 [US1] Implement per‑directory `max_folders`/`max_files` summaries (`# N more files|folders`) (/home/rafael/Workspace/Repos/rafael/a0-local/python/helpers/files.py)
25 +- [X] T013 [US1] Implement global `max_lines` with finish‑current‑depth behavior before stopping descent (/home/rafael/Workspace/Repos/rafael/a0-local/python/helpers/files.py)
26 +- [X] T014 [US1] Implement ASCII render (`├──`/`└──`, folder `/`, `#` comment prefix) and return `OUTPUT_MODE_STRING` string (/home/rafael/Workspace/Repos/rafael/a0-local/python/helpers/files.py)
27 +- [X] T015 [P] [US1] Write snapshot tests for string mode output (glyphs, folder suffix, comments) (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_string.py)
28 +- [X] T016 [P] [US1] Write tests for ignore semantics and per‑directory/global limits (may exercise multiple modes) (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_ignore_and_limits.py)
29 +- [X] T024 [US1] Implement invalid-input error handling: raise clear exception for non-existent `relative_path` (/home/rafael/Workspace/Repos/rafael/a0-local/python/helpers/files.py)
30 +- [X] T025 [P] [US1] Add test asserting clear exception/message for non-existent `relative_path` (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_string.py)
31 +- [X] T026 [P] [US1] Write test: empty directory renders top-level only, no errors (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_string.py)
32 +- [X] T027 [P] [US1] Write test: `folders_first=False` ordering in string mode (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_string.py)
33 +- [X] T028 [P] [US1] Write tests: sort variants (name/created/modified × asc/desc) in string mode (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_string.py)
34 +- [X] T029 [P] [US1] Write tests: `max_depth` behaviors (0 unlimited, 1, 2) in string mode (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_string.py)
35 +- [X] T030 [P] [US1] Write test: `max_lines` finishes current depth before stopping (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_ignore_and_limits.py)
36 +- [X] T031 [P] [US1] Write tests: per-directory `max_folders`/`max_files` with correct summary comments (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_ignore_and_limits.py)
37 +- [X] T032 [P] [US1] Write tests: ignore inline semantics including `!` negation, directory-only `/`, `**` recursion (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_ignore_and_limits.py)
38 +- [X] T033 [P] [US1] Write tests: ignore from file using `file:`, `file://`, `file:///` resolution (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_ignore_and_limits.py)
39 +- [X] T048 [P] [US1] Write regression test that fails when traversal output becomes depth-first (assert breadth-first ordering across multiple depths) (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_string.py)
40 +- [X] T034 [P] [US1] Execute US1 tests and record results (pytest selection) (/home/rafael/Workspace/Repos/rafael/a0-local/tests/)
41 +- [X] T035 [P] [US1] Baseline/update snapshots using external fixture files; ensure glyphs and formatting preserved (/home/rafael/Workspace/Repos/rafael/a0-local/tests/fixtures/file_tree/)
42 +- [X] T046 [P] [US1] Create snapshot fixtures directory and baseline files (e.g., `us1_string_default.txt`, permutations) (/home/rafael/Workspace/Repos/rafael/a0-local/tests/fixtures/file_tree/)
43 +- [X] T047 [P] [US1] Update string-mode tests to load expected snapshots from fixture files (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_string.py)
44 +
45 +## Phase 4 — [US2] Flat Structured Listing (P2)
46 +
47 +- [X] T017 [US2] Implement `OUTPUT_MODE_FLAT` returning list of dicts with fields (`name`, `level`, `type`, `created`, `modified`, `text`) (/home/rafael/Workspace/Repos/rafael/a0-local/python/helpers/files.py)
48 +- [X] T018 [P] [US2] Write tests validating flat items, tz‑aware UTC datetimes, ordering and levels (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_structured.py)
49 +- [X] T036 [P] [US2] Write test: all fields present and `created`/`modified` are tz-aware UTC (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_structured.py)
50 +- [X] T037 [P] [US2] Write test: levels align with hierarchy; files/comments have no `items` (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_structured.py)
51 +- [X] T038 [P] [US2] Write tests: ordering respects `folders_first` and sort variants in flat mode (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_structured.py)
52 +- [X] T039 [P] [US2] Write tests: ignore semantics mirrored in flat mode (inline and file-based) (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_structured.py)
53 +- [X] T040 [P] [US2] Execute US2 tests and record results (pytest selection) (/home/rafael/Workspace/Repos/rafael/a0-local/tests/)
54 +
55 +## Phase 5 — [US3] Nested Structured Listing (P3)
56 +
57 +- [X] T019 [US3] Implement `OUTPUT_MODE_NESTED` building hierarchical `items` arrays; honor `max_depth` (/home/rafael/Workspace/Repos/rafael/a0-local/python/helpers/files.py)
58 +- [X] T020 [P] [US3] Write tests validating nested structure, children arrays, and depth limiting (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_structured.py)
59 +- [X] T041 [P] [US3] Write test: nested items arrays for folders; files/comments have `None`/empty (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_structured.py)
60 +- [X] T042 [P] [US3] Write test: `max_depth` truncation represented appropriately (e.g., comment items) (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_structured.py)
61 +- [X] T043 [P] [US3] Write test: deterministic ordering within each level in nested mode (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_structured.py)
62 +- [X] T044 [P] [US3] Execute US3 tests and record results (pytest selection) (/home/rafael/Workspace/Repos/rafael/a0-local/tests/)
63 +- [X] T049 [P] [US3] Write tests: ignore semantics (including `!`, `/`, `**`, file-based patterns) reflected in `OUTPUT_MODE_NESTED` results (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_structured.py)
64 +
65 +## Final Phase — Polish & Cross‑Cutting
66 +
67 +- [X] T022 [P] Add automated performance guard test using `time.perf_counter()` to assert a ≥5k entry synthetic tree with `max_lines=200` completes within 2.0 seconds while verifying deterministic ordering and summary comments (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_ignore_and_limits.py)
68 +- [X] T023 [P] Align contract doc with final signature/details if needed (/home/rafael/Workspace/Repos/rafael/a0-local/specs/001-file-tree-utility/contracts/file_tree.md)
69 +- [X] T045 [P] Run full test suite and collect coverage report (target 100% for new code) (/home/rafael/Workspace/Repos/rafael/a0-local/tests/)
70 +
71 +## Phase 6 — Extended Validation
72 +
73 +- [X] T050 [P] Expand string-mode coverage to validate all sorting key/direction combinations and deep `max_lines` truncation (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_string.py)
74 +- [X] T051 [P] Add negative/invalid parameter tests (unsupported sort tuples, invalid output modes, negative limits, missing ignore file) (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_invalid.py)
75 +- [X] T052 [P] Validate nested summary comment generation when per-directory limits truncate children (/home/rafael/Workspace/Repos/rafael/a0-local/tests/test_file_tree_structured.py)
76 +
77 +---
78 +
79 +## Dependencies (Story Order)
80 +- US1 → US2 → US3 (string mode first, then flat, then nested). Foundational Phase 2 must precede all stories.
81 +
82 +## Parallel Execution Examples
83 +- US1: Implement `_resolve_ignore_patterns` [P] in parallel with writing initial snapshot tests file.
84 +- US2: Add flat‑mode fields and write field assertions [P] concurrently.
85 +- US3: Build `_to_nested_structure` [P] while extending structured tests for nested validation.
86 +
87 +## Implementation Strategy
88 +- MVP scope: Complete US1 (string mode) with ignore/limits and snapshot tests.
89 +- Incrementally add US2 (flat) and US3 (nested), keeping tests green after each phase.
90 +
91 +## Validation
92 +- All tasks follow required checklist format with IDs, optional [P], optional [US?], and explicit file paths.
tests/fixtures/file_tree/string_breadth_first.txt new
+10
@@ -0,0 +1,10 @@
1 +tmp/tests/file_tree/string_breadth_first/
2 +├── alpha/
3 +│ ├── nested/
4 +│ │ └── inner.txt
5 +│ └── alpha_file.txt
6 +├── beta/
7 +│ └── beta_file.txt
8 +├── zeta/
9 +├── a.txt
10 +└── b.txt
tests/fixtures/file_tree/string_ignore_limits.txt new
+14
@@ -0,0 +1,14 @@
1 +tmp/tests/file_tree/string_ignore_limits/
2 +├── .treeignore
3 +├── notes.md
4 +├── logs/
5 +│ └── 2025.log
6 +└── src/
7 + ├── main.py
8 + ├── utils.py
9 + ├── cache/
10 + │ └── keep.txt
11 + └── modules/
12 + ├── a.py
13 + ├── b.py
14 + └── c.py
tests/test_file_tree_ignore_and_limits.py new
+235
@@ -0,0 +1,235 @@
1 +from __future__ import annotations
2 +
3 +import os
4 +import time
5 +from contextlib import contextmanager
6 +from pathlib import Path
7 +
8 +import pytest
9 +
10 +from python.helpers.files import (
11 + OUTPUT_MODE_FLAT,
12 + OUTPUT_MODE_STRING,
13 + SORT_ASC,
14 + SORT_BY_NAME,
15 + create_dir,
16 + delete_dir,
17 + file_tree,
18 + get_abs_path,
19 + write_file,
20 +)
21 +
22 +
23 +@contextmanager
24 +def _project_directory(relative_path: str):
25 + delete_dir(relative_path)
26 + try:
27 + create_dir(relative_path)
28 + yield Path(get_abs_path(relative_path))
29 + finally:
30 + delete_dir(relative_path)
31 +
32 +
33 +def _materialize_structure(base_rel: str, structure: dict[str, object]) -> None:
34 + for name, value in structure.items():
35 + rel = os.path.join(base_rel, name)
36 + if isinstance(value, dict):
37 + create_dir(rel)
38 + _materialize_structure(rel, value)
39 + else:
40 + write_file(rel, "" if value is None else str(value))
41 +
42 +
43 +def test_file_tree_ignore_file_reference_variants() -> None:
44 + base_rel = "tmp/tests/file_tree/ignore_file_variants"
45 + absolute_ignore = Path(get_abs_path(base_rel)) / "absolute.ignore"
46 + with _project_directory(base_rel):
47 + _materialize_structure(
48 + base_rel,
49 + {
50 + "app": {
51 + "build.tmp": "",
52 + "main.py": "",
53 + "README.md": "",
54 + },
55 + },
56 + )
57 + root_ignore = Path(get_abs_path(base_rel)) / ".treeignore"
58 + root_ignore.write_text("*.tmp\n", encoding="utf-8")
59 + absolute_ignore.write_text("README.md\n", encoding="utf-8")
60 +
61 + inline_result = file_tree(
62 + base_rel,
63 + ignore="file:.treeignore",
64 + output_mode=OUTPUT_MODE_FLAT,
65 + )
66 + abs_result = file_tree(
67 + base_rel,
68 + ignore=f"file:{absolute_ignore}",
69 + output_mode=OUTPUT_MODE_FLAT,
70 + )
71 + url_result = file_tree(
72 + base_rel,
73 + ignore="file://.treeignore",
74 + output_mode=OUTPUT_MODE_FLAT,
75 + )
76 + inline_patterns = file_tree(
77 + base_rel,
78 + ignore="*.tmp\n!build.tmp",
79 + output_mode=OUTPUT_MODE_FLAT,
80 + )
81 +
82 + inline_names = {item["name"] for item in inline_result}
83 + abs_names = {item["name"] for item in abs_result}
84 + url_names = {item["name"] for item in url_result}
85 + inline_pattern_names = {item["name"] for item in inline_patterns}
86 +
87 + assert "build.tmp" not in inline_names
88 + assert "README.md" in inline_names
89 + assert "README.md" not in abs_names
90 + assert inline_names == url_names
91 + assert "build.tmp" in inline_pattern_names
92 +
93 +
94 +def test_file_tree_limits_emit_summary_comments() -> None:
95 + base_rel = "tmp/tests/file_tree/limits_summary"
96 + with _project_directory(base_rel):
97 + _materialize_structure(
98 + base_rel,
99 + {
100 + "pkg": {
101 + "a.py": "",
102 + "b.py": "",
103 + "c.py": "",
104 + "d.py": "",
105 + "e.py": "",
106 + "dir1": {},
107 + "dir2": {},
108 + "dir3": {},
109 + "dir4": {},
110 + }
111 + },
112 + )
113 +
114 + result = file_tree(
115 + base_rel,
116 + folders_first=True,
117 + sort=(SORT_BY_NAME, SORT_ASC),
118 + max_folders=2,
119 + max_files=2,
120 + output_mode=OUTPUT_MODE_STRING,
121 + )
122 +
123 + lines = result.splitlines()
124 + assert any("# 2 more folders" in line for line in lines)
125 + assert any("# 3 more files" in line for line in lines)
126 +
127 +
128 +def test_file_tree_global_max_lines() -> None:
129 + base_rel = "tmp/tests/file_tree/global_max_lines"
130 + with _project_directory(base_rel):
131 + _materialize_structure(
132 + base_rel,
133 + {
134 + "dirA": {"a.txt": "", "b.txt": ""},
135 + "dirB": {"c.txt": "", "d.txt": ""},
136 + "x.txt": "",
137 + "y.txt": "",
138 + },
139 + )
140 +
141 + result = file_tree(
142 + base_rel,
143 + max_lines=5,
144 + folders_first=True,
145 + sort=(SORT_BY_NAME, SORT_ASC),
146 + output_mode=OUTPUT_MODE_STRING,
147 + )
148 + flat = file_tree(
149 + base_rel,
150 + max_lines=5,
151 + folders_first=True,
152 + sort=(SORT_BY_NAME, SORT_ASC),
153 + output_mode=OUTPUT_MODE_FLAT,
154 + )
155 +
156 + lines = result.splitlines()
157 + flat_levels = [item["level"] for item in flat]
158 + assert all(level <= 2 for level in flat_levels)
159 + top_level_names = {item["name"] for item in flat if item["level"] == 1}
160 + assert top_level_names == {"dirA", "dirB", "x.txt", "y.txt"}
161 + assert not any("│ " in line for line in lines)
162 +
163 +
164 +def test_file_tree_limits_exact_counts_no_comment() -> None:
165 + base_rel = "tmp/tests/file_tree/limits_exact"
166 + with _project_directory(base_rel):
167 + _materialize_structure(
168 + base_rel,
169 + {
170 + "pkg": {
171 + "a.py": "",
172 + "b.py": "",
173 + "dir1": {},
174 + "dir2": {},
175 + }
176 + },
177 + )
178 +
179 + result = file_tree(
180 + base_rel,
181 + folders_first=True,
182 + max_folders=2,
183 + max_files=2,
184 + sort=(SORT_BY_NAME, SORT_ASC),
185 + output_mode=OUTPUT_MODE_STRING,
186 + )
187 +
188 + assert "# " not in result
189 +
190 +
191 +def test_file_tree_limits_single_overflow_flat_mode() -> None:
192 + base_rel = "tmp/tests/file_tree/limits_single_flat"
193 + with _project_directory(base_rel):
194 + _materialize_structure(
195 + base_rel,
196 + {
197 + "pkg": {
198 + "dir_a": {},
199 + "dir_b": {},
200 + }
201 + },
202 + )
203 +
204 + flat = file_tree(
205 + base_rel,
206 + folders_first=True,
207 + max_folders=1,
208 + sort=(SORT_BY_NAME, SORT_ASC),
209 + output_mode=OUTPUT_MODE_FLAT,
210 + )
211 +
212 + assert all(item["type"] != "comment" for item in flat)
213 + folder_names = [item["name"] for item in flat if item["type"] == "folder"]
214 + assert folder_names == ["pkg", "dir_a", "dir_b"]
215 +
216 +
217 +def test_file_tree_performance_guard_large_directory() -> None:
218 + base_rel = "tmp/tests/file_tree/performance_guard"
219 + with _project_directory(base_rel):
220 + structure = {f"dir{i}": {} for i in range(50)}
221 + files = {f"file{i}.txt": "" for i in range(4900)}
222 + structure.update(files)
223 + _materialize_structure(base_rel, structure)
224 +
225 + start = time.perf_counter()
226 + file_tree(
227 + base_rel,
228 + max_lines=200,
229 + folders_first=True,
230 + sort=(SORT_BY_NAME, SORT_ASC),
231 + output_mode=OUTPUT_MODE_STRING,
232 + )
233 + duration = time.perf_counter() - start
234 +
235 + assert duration < 2.0
tests/test_file_tree_invalid.py new
+86
@@ -0,0 +1,86 @@
1 +from __future__ import annotations
2 +
3 +import os
4 +from contextlib import contextmanager
5 +from pathlib import Path
6 +
7 +import pytest
8 +
9 +from python.helpers.files import (
10 + OUTPUT_MODE_STRING,
11 + SORT_ASC,
12 + SORT_BY_NAME,
13 + create_dir,
14 + delete_dir,
15 + file_tree,
16 + get_abs_path,
17 + write_file,
18 +)
19 +
20 +
21 +@contextmanager
22 +def _project_directory(relative_path: str):
23 + delete_dir(relative_path)
24 + try:
25 + create_dir(relative_path)
26 + yield Path(get_abs_path(relative_path))
27 + finally:
28 + delete_dir(relative_path)
29 +
30 +
31 +def _materialize_structure(base_rel: str) -> None:
32 + write_file(os.path.join(base_rel, "file.txt"), "content")
33 +
34 +
35 +def test_file_tree_invalid_sort_key() -> None:
36 + base_rel = "tmp/tests/file_tree/invalid_sort_key"
37 + with _project_directory(base_rel):
38 + _materialize_structure(base_rel)
39 + with pytest.raises(ValueError):
40 + file_tree(
41 + base_rel,
42 + sort=("unsupported", SORT_ASC),
43 + output_mode=OUTPUT_MODE_STRING,
44 + )
45 +
46 +
47 +def test_file_tree_invalid_sort_direction() -> None:
48 + base_rel = "tmp/tests/file_tree/invalid_sort_direction"
49 + with _project_directory(base_rel):
50 + _materialize_structure(base_rel)
51 + with pytest.raises(ValueError):
52 + file_tree(
53 + base_rel,
54 + sort=(SORT_BY_NAME, "ascending"),
55 + output_mode=OUTPUT_MODE_STRING,
56 + )
57 +
58 +
59 +def test_file_tree_invalid_output_mode() -> None:
60 + base_rel = "tmp/tests/file_tree/invalid_output_mode"
61 + with _project_directory(base_rel):
62 + _materialize_structure(base_rel)
63 + with pytest.raises(ValueError):
64 + file_tree(base_rel, output_mode="yaml")
65 +
66 +
67 +def test_file_tree_negative_depth_and_lines() -> None:
68 + base_rel = "tmp/tests/file_tree/invalid_depth_lines"
69 + with _project_directory(base_rel):
70 + _materialize_structure(base_rel)
71 + with pytest.raises(ValueError):
72 + file_tree(base_rel, max_depth=-1)
73 + with pytest.raises(ValueError):
74 + file_tree(base_rel, max_lines=-5)
75 +
76 +
77 +def test_file_tree_missing_ignore_file() -> None:
78 + base_rel = "tmp/tests/file_tree/missing_ignore"
79 + with _project_directory(base_rel):
80 + _materialize_structure(base_rel)
81 + with pytest.raises(FileNotFoundError):
82 + file_tree(
83 + base_rel,
84 + ignore="file:missing.ignore",
85 + output_mode=OUTPUT_MODE_STRING,
86 + )
tests/test_file_tree_string.py new
+493
@@ -0,0 +1,493 @@
1 +from __future__ import annotations
2 +
3 +import os
4 +from contextlib import contextmanager
5 +from pathlib import Path
6 +import time
7 +
8 +import pytest
9 +
10 +from python.helpers.files import (
11 + OUTPUT_MODE_FLAT,
12 + OUTPUT_MODE_STRING,
13 + SORT_ASC,
14 + SORT_BY_CREATED,
15 + SORT_BY_MODIFIED,
16 + SORT_BY_NAME,
17 + SORT_DESC,
18 + create_dir,
19 + delete_dir,
20 + file_tree,
21 + get_abs_path,
22 + write_file,
23 +)
24 +
25 +
26 +FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" / "file_tree"
27 +
28 +
29 +def _load_fixture(name: str) -> str:
30 + return (FIXTURES_DIR / name).read_text(encoding="utf-8").rstrip("\n")
31 +
32 +
33 +@contextmanager
34 +def _project_directory(relative_path: str):
35 + delete_dir(relative_path)
36 + try:
37 + create_dir(relative_path)
38 + yield Path(get_abs_path(relative_path))
39 + finally:
40 + delete_dir(relative_path)
41 +
42 +
43 +def _materialize_structure(base_rel: str, structure: dict[str, object]) -> None:
44 + for entry, value in structure.items():
45 + rel = os.path.join(base_rel, entry)
46 + if isinstance(value, dict):
47 + create_dir(rel)
48 + _materialize_structure(rel, value)
49 + else:
50 + content = "" if value is None else str(value)
51 + write_file(rel, content)
52 +
53 +
54 +def _set_entry_times(relative_path: str, timestamp: float) -> None:
55 + abs_path = get_abs_path(relative_path)
56 + os.utime(abs_path, (timestamp, timestamp))
57 + time.sleep(0.01)
58 +
59 +
60 +def _extract_tree_labels(result: str) -> list[str]:
61 + labels: list[str] = []
62 + for line in result.splitlines()[1:]:
63 + if "── " in line:
64 + labels.append(line.split("── ", 1)[1])
65 + return labels
66 +
67 +
68 +def test_file_tree_string_breadth_first_snapshot() -> None:
69 + base_rel = "tmp/tests/file_tree/string_breadth_first"
70 + with _project_directory(base_rel):
71 + _materialize_structure(
72 + base_rel,
73 + {
74 + "alpha": {
75 + "alpha_file.txt": "alpha",
76 + "nested": {"inner.txt": "inner"},
77 + },
78 + "beta": {
79 + "beta_file.txt": "beta",
80 + },
81 + "zeta": {},
82 + "a.txt": "A",
83 + "b.txt": "B",
84 + },
85 + )
86 +
87 + result = file_tree(
88 + base_rel,
89 + folders_first=True,
90 + sort=(SORT_BY_NAME, SORT_ASC),
91 + output_mode=OUTPUT_MODE_STRING,
92 + )
93 +
94 + assert result == _load_fixture("string_breadth_first.txt")
95 +
96 +
97 +def test_file_tree_string_ignore_and_limits() -> None:
98 + base_rel = "tmp/tests/file_tree/string_ignore_limits"
99 + ignore_file_rel = os.path.join(base_rel, ".treeignore")
100 + with _project_directory(base_rel):
101 + _materialize_structure(
102 + base_rel,
103 + {
104 + "src": {
105 + "main.py": "print('hello')",
106 + "utils.py": "pass",
107 + "tmp.tmp": "",
108 + "cache": {
109 + "cached.txt": "",
110 + "keep.txt": "",
111 + },
112 + "modules": {
113 + "a.py": "",
114 + "b.py": "",
115 + "c.py": "",
116 + },
117 + },
118 + "logs": {
119 + "2024.log": "",
120 + "2025.log": "",
121 + },
122 + "notes.md": "",
123 + "build.tmp": "",
124 + },
125 + )
126 +
127 + write_file(
128 + ignore_file_rel,
129 + "\n".join(
130 + [
131 + "*.tmp",
132 + "cache/",
133 + "!src/cache/keep.txt",
134 + "logs/",
135 + "!logs/2025.log",
136 + ]
137 + ),
138 + )
139 +
140 + result = file_tree(
141 + base_rel,
142 + folders_first=False,
143 + sort=(SORT_BY_NAME, SORT_ASC),
144 + ignore="file:.treeignore",
145 + max_folders=1,
146 + max_files=2,
147 + max_lines=12,
148 + output_mode=OUTPUT_MODE_STRING,
149 + )
150 +
151 + assert result == _load_fixture("string_ignore_limits.txt")
152 +
153 +
154 +def test_file_tree_string_single_overflow_promotes_entry() -> None:
155 + base_rel = "tmp/tests/file_tree/string_single_overflow"
156 + with _project_directory(base_rel):
157 + _materialize_structure(
158 + base_rel,
159 + {
160 + "first_dir": {},
161 + "second_dir": {},
162 + },
163 + )
164 +
165 + result = file_tree(
166 + base_rel,
167 + folders_first=True,
168 + max_folders=1,
169 + sort=(SORT_BY_NAME, SORT_DESC),
170 + output_mode=OUTPUT_MODE_STRING,
171 + )
172 +
173 + assert "# 1 more folder" not in result
174 + lines = result.splitlines()
175 + assert any(line.endswith("first_dir/") for line in lines)
176 + assert any(line.endswith("second_dir/") for line in lines)
177 +
178 +
179 +def test_file_tree_string_summary_comment_after_children() -> None:
180 + base_rel = "tmp/tests/file_tree/string_comment_order"
181 + with _project_directory(base_rel):
182 + _materialize_structure(
183 + base_rel,
184 + {
185 + "logs": {
186 + "2025.log": "",
187 + },
188 + "alpha": {},
189 + "beta": {},
190 + "gamma": {},
191 + },
192 + )
193 +
194 + result = file_tree(
195 + base_rel,
196 + folders_first=True,
197 + max_folders=1,
198 + sort=(SORT_BY_NAME, SORT_DESC),
199 + output_mode=OUTPUT_MODE_STRING,
200 + )
201 +
202 + lines = result.splitlines()
203 + comment_index = next(i for i, line in enumerate(lines) if "# 3 more folders" in line)
204 + logs_index = next(i for i, line in enumerate(lines) if line.endswith("logs/"))
205 + child_index = next(i for i, line in enumerate(lines) if line.strip().endswith("2025.log"))
206 + assert logs_index < child_index < comment_index
207 +
208 +
209 +def test_file_tree_string_sort_all_keys() -> None:
210 + base_rel = "tmp/tests/file_tree/string_sort_all_keys"
211 + with _project_directory(base_rel) as base_abs:
212 + _materialize_structure(
213 + base_rel,
214 + {
215 + "folder_alpha": {},
216 + "folder_beta": {},
217 + "file_first.txt": "",
218 + "file_second.txt": "",
219 + "file_third.txt": "",
220 + },
221 + )
222 + base_timestamp = time.time()
223 + for offset, name in enumerate(
224 + [
225 + "folder_alpha",
226 + "folder_beta",
227 + "file_first.txt",
228 + "file_second.txt",
229 + "file_third.txt",
230 + ],
231 + start=1,
232 + ):
233 + _set_entry_times(os.path.join(base_rel, name), base_timestamp + offset)
234 +
235 + stats: dict[str, dict[str, object]] = {}
236 + for name in sorted(os.listdir(base_abs)):
237 + abs_entry = base_abs / name
238 + stat = os.stat(abs_entry, follow_symlinks=False)
239 + stats[name] = {
240 + "is_dir": abs_entry.is_dir(),
241 + "created": stat.st_ctime,
242 + "modified": stat.st_mtime,
243 + }
244 +
245 + sort_variants = [
246 + (SORT_BY_NAME, SORT_ASC),
247 + (SORT_BY_NAME, SORT_DESC),
248 + (SORT_BY_CREATED, SORT_ASC),
249 + (SORT_BY_CREATED, SORT_DESC),
250 + (SORT_BY_MODIFIED, SORT_ASC),
251 + (SORT_BY_MODIFIED, SORT_DESC),
252 + ]
253 +
254 + results = {
255 + variant: file_tree(
256 + base_rel,
257 + folders_first=True,
258 + sort=variant,
259 + output_mode=OUTPUT_MODE_STRING,
260 + )
261 + for variant in sort_variants
262 + }
263 +
264 + def expected_labels(key: str, direction: str) -> list[str]:
265 + reverse = direction == SORT_DESC
266 + folders = [(name, stats[name]) for name in stats if stats[name]["is_dir"]]
267 + files = [(name, stats[name]) for name in stats if not stats[name]["is_dir"]]
268 +
269 + def sort_key(item):
270 + name, meta = item
271 + if key == SORT_BY_NAME:
272 + return name.casefold()
273 + if key == SORT_BY_CREATED:
274 + return meta["created"]
275 + return meta["modified"]
276 +
277 + folders_sorted = sorted(folders, key=sort_key, reverse=reverse)
278 + files_sorted = sorted(files, key=sort_key, reverse=reverse)
279 + labels = [f"{name}/" for name, _ in folders_sorted]
280 + labels.extend(name for name, _ in files_sorted)
281 + return labels
282 +
283 + for key, direction in sort_variants:
284 + labels = _extract_tree_labels(results[(key, direction)])
285 + assert labels == expected_labels(key, direction)
286 +
287 +
288 +def test_file_tree_string_missing_path() -> None:
289 + with pytest.raises(FileNotFoundError):
290 + file_tree(
291 + "tmp/tests/file_tree/does_not_exist",
292 + output_mode=OUTPUT_MODE_STRING,
293 + )
294 +
295 +
296 +def test_file_tree_string_empty_directory() -> None:
297 + base_rel = "tmp/tests/file_tree/string_empty"
298 + with _project_directory(base_rel):
299 + result = file_tree(base_rel, output_mode=OUTPUT_MODE_STRING)
300 + assert result == "tmp/tests/file_tree/string_empty/"
301 +
302 +
303 +def test_file_tree_string_folders_first_disabled() -> None:
304 + base_rel = "tmp/tests/file_tree/string_folders_first"
305 + with _project_directory(base_rel):
306 + _materialize_structure(
307 + base_rel,
308 + {
309 + "folder": {"child.txt": ""},
310 + "alpha.txt": "",
311 + },
312 + )
313 + result = file_tree(
314 + base_rel,
315 + folders_first=False,
316 + sort=(SORT_BY_NAME, SORT_ASC),
317 + output_mode=OUTPUT_MODE_STRING,
318 + )
319 + lines = result.splitlines()
320 + assert lines[1] == "├── alpha.txt" # file precedes folder when folders_first=False
321 +
322 +
323 +def test_file_tree_string_max_depth_levels() -> None:
324 + base_rel = "tmp/tests/file_tree/string_max_depth"
325 + with _project_directory(base_rel):
326 + _materialize_structure(
327 + base_rel,
328 + {
329 + "level1": {
330 + "level2": {
331 + "level3.txt": "",
332 + },
333 + },
334 + },
335 + )
336 + depth_one = file_tree(
337 + base_rel,
338 + max_depth=1,
339 + output_mode=OUTPUT_MODE_STRING,
340 + )
341 + depth_two = file_tree(
342 + base_rel,
343 + max_depth=2,
344 + output_mode=OUTPUT_MODE_STRING,
345 + )
346 + assert depth_one.splitlines() == ["tmp/tests/file_tree/string_max_depth/", "└── level1/"]
347 + assert "level2/" in depth_two
348 +
349 +
350 +def test_file_tree_string_bfs_regression_flat_levels() -> None:
351 + base_rel = "tmp/tests/file_tree/string_bfs_regression"
352 + with _project_directory(base_rel):
353 + _materialize_structure(
354 + base_rel,
355 + {
356 + "dirA": {"a1.txt": "", "a2.txt": ""},
357 + "dirB": {"b1.txt": ""},
358 + "root.txt": "",
359 + },
360 + )
361 + flat = file_tree(
362 + base_rel,
363 + folders_first=True,
364 + sort=(SORT_BY_NAME, SORT_ASC),
365 + output_mode=OUTPUT_MODE_FLAT,
366 + )
367 + levels = [item["level"] for item in flat]
368 + assert levels[0] == 1
369 + for current, nxt in zip(levels, levels[1:]):
370 + assert nxt <= current + 1, "level jumps exceed single depth step"
371 +
372 +
373 +def test_file_tree_string_deep_structure_limits() -> None:
374 + base_rel = "tmp/tests/file_tree/string_deep_limits"
375 + with _project_directory(base_rel):
376 + _materialize_structure(
377 + base_rel,
378 + {
379 + "layer1_a": {
380 + "layer2_a": {
381 + "layer3_a": {
382 + "layer4_a": {
383 + "layer5_a.txt": "",
384 + }
385 + }
386 + }
387 + },
388 + "layer1_b": {
389 + "layer2_b": {
390 + "layer3_b": {
391 + "layer4_b": {
392 + "layer5_b.txt": "",
393 + }
394 + }
395 + }
396 + },
397 + "root_file.txt": "",
398 + },
399 + )
400 +
401 + result = file_tree(
402 + base_rel,
403 + max_lines=6,
404 + output_mode=OUTPUT_MODE_STRING,
405 + )
406 +
407 + lines = result.splitlines()
408 + deep_names = {"layer4_a/", "layer5_a.txt", "layer4_b/", "layer5_b.txt"}
409 + combined = "\n".join(lines)
410 + for name in deep_names:
411 + assert name not in combined
412 + assert lines[1:] == [
413 + "├── layer1_b/",
414 + "│ └── layer2_b/",
415 + "│ └── layer3_b/",
416 + "├── layer1_a/",
417 + "│ └── layer2_a/",
418 + "│ └── layer3_a/",
419 + "└── root_file.txt",
420 + ]
421 +
422 +
423 +def test_file_tree_string_files_first_max_lines_modified() -> None:
424 + base_rel = "tmp/tests/file_tree/string_files_first_modified"
425 + with _project_directory(base_rel):
426 + _materialize_structure(
427 + base_rel,
428 + {
429 + "dir": {
430 + "inner_a.txt": "",
431 + "inner_b.txt": "",
432 + },
433 + "alpha.txt": "",
434 + "beta.txt": "",
435 + "gamma.txt": "",
436 + },
437 + )
438 + base_ts = time.time()
439 + for offset, rel_path in enumerate(
440 + [
441 + "dir",
442 + os.path.join("dir", "inner_a.txt"),
443 + os.path.join("dir", "inner_b.txt"),
444 + "alpha.txt",
445 + "beta.txt",
446 + "gamma.txt",
447 + ],
448 + start=1,
449 + ):
450 + _set_entry_times(os.path.join(base_rel, rel_path), base_ts + offset)
451 +
452 + result = file_tree(
453 + base_rel,
454 + folders_first=False,
455 + sort=(SORT_BY_MODIFIED, SORT_DESC),
456 + max_lines=4,
457 + output_mode=OUTPUT_MODE_STRING,
458 + )
459 +
460 + lines = result.splitlines()
461 + assert len(lines) == 5 # root + 4 entries
462 + assert lines[1].endswith("gamma.txt")
463 + assert "inner_b.txt" not in result
464 +
465 +
466 +def test_file_tree_string_zero_file_limit_unlimited() -> None:
467 + base_rel = "tmp/tests/file_tree/string_zero_limit"
468 + with _project_directory(base_rel):
469 + _materialize_structure(
470 + base_rel,
471 + {
472 + "folder1": {},
473 + "folder2": {},
474 + "folder3": {},
475 + "a.py": "",
476 + "b.py": "",
477 + "c.py": "",
478 + },
479 + )
480 +
481 + result = file_tree(
482 + base_rel,
483 + folders_first=True,
484 + sort=(SORT_BY_NAME, SORT_ASC),
485 + max_folders=1,
486 + max_files=0,
487 + output_mode=OUTPUT_MODE_STRING,
488 + )
489 +
490 + lines = result.splitlines()
491 + assert any("# 2 more folders" in line for line in lines)
492 + assert all("more files" not in line for line in lines)
493 + assert "a.py" in result and "c.py" in result
tests/test_file_tree_structured.py new
+578
@@ -0,0 +1,578 @@
1 +from __future__ import annotations
2 +
3 +import os
4 +import time
5 +from contextlib import contextmanager
6 +from datetime import datetime, timezone
7 +from pathlib import Path
8 +
9 +import pytest
10 +
11 +from python.helpers.files import (
12 + OUTPUT_MODE_FLAT,
13 + OUTPUT_MODE_NESTED,
14 + SORT_ASC,
15 + SORT_BY_CREATED,
16 + SORT_BY_MODIFIED,
17 + SORT_BY_NAME,
18 + SORT_DESC,
19 + create_dir,
20 + delete_dir,
21 + file_tree,
22 + get_abs_path,
23 + write_file,
24 +)
25 +
26 +
27 +@contextmanager
28 +def _project_directory(relative_path: str):
29 + delete_dir(relative_path)
30 + try:
31 + create_dir(relative_path)
32 + yield Path(get_abs_path(relative_path))
33 + finally:
34 + delete_dir(relative_path)
35 +
36 +
37 +def _materialize_structure(base_rel: str, structure: dict[str, object]) -> None:
38 + for name, value in structure.items():
39 + rel = os.path.join(base_rel, name)
40 + if isinstance(value, dict):
41 + create_dir(rel)
42 + _materialize_structure(rel, value)
43 + else:
44 + write_file(rel, "" if value is None else str(value))
45 +
46 +
47 +def _set_entry_times(relative_path: str, timestamp: float) -> None:
48 + abs_path = get_abs_path(relative_path)
49 + os.utime(abs_path, (timestamp, timestamp))
50 + time.sleep(0.01)
51 +
52 +
53 +def _collect_expected_stats(base_abs: Path) -> dict[str, tuple[datetime, datetime]]:
54 + results: dict[str, tuple[datetime, datetime]] = {}
55 + for current_path, dirnames, filenames in os.walk(base_abs):
56 + for name in dirnames + filenames:
57 + path = os.path.join(current_path, name)
58 + stat = os.stat(path, follow_symlinks=False)
59 + created = datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc)
60 + modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
61 + results[name] = (created, modified)
62 + return results
63 +
64 +
65 +def _flatten_nested(items: list[dict]) -> list[dict]:
66 + result: list[dict] = []
67 + for item in items:
68 + result.append(item)
69 + if item.get("items"):
70 + result.extend(_flatten_nested(item["items"]))
71 + return result
72 +
73 +
74 +def _assert_datetime_order(values: list[datetime], direction: str) -> None:
75 + expected = sorted(values, reverse=direction == SORT_DESC)
76 + assert values == expected
77 +
78 +
79 +def test_file_tree_flat_metadata_and_levels() -> None:
80 + base_rel = "tmp/tests/file_tree/flat_metadata"
81 + with _project_directory(base_rel):
82 + _materialize_structure(
83 + base_rel,
84 + {
85 + "dirA": {
86 + "file1.txt": "",
87 + "file2.txt": "",
88 + },
89 + "dirB": {
90 + "sub": {
91 + "inner.txt": "",
92 + },
93 + },
94 + "root.txt": "",
95 + },
96 + )
97 +
98 + flat = file_tree(
99 + base_rel,
100 + folders_first=True,
101 + sort=(SORT_BY_NAME, SORT_ASC),
102 + output_mode=OUTPUT_MODE_FLAT,
103 + )
104 + flat_files_first = file_tree(
105 + base_rel,
106 + folders_first=False,
107 + sort=(SORT_BY_NAME, SORT_ASC),
108 + output_mode=OUTPUT_MODE_FLAT,
109 + )
110 +
111 + assert all("items" in item for item in flat)
112 + assert all(item["type"] in {"file", "folder", "comment"} for item in flat)
113 +
114 + # Ensure datetime fields are timezone-aware UTC
115 + for item in flat:
116 + assert item["created"].tzinfo is not None
117 + assert item["modified"].tzinfo is not None
118 +
119 + # Level ordering: first three entries should be level 1 (dirA, dirB, root.txt)
120 + levels = [item["level"] for item in flat]
121 + level_one_names = [item["name"] for item in flat if item["level"] == 1]
122 + assert level_one_names == ["dirA", "dirB", "root.txt"]
123 + assert flat[0]["type"] == "folder"
124 + assert flat_files_first[0]["type"] == "file"
125 +
126 +
127 +def test_file_tree_flat_sort_all_keys() -> None:
128 + base_rel = "tmp/tests/file_tree/flat_sort_all_keys"
129 + with _project_directory(base_rel):
130 + _materialize_structure(
131 + base_rel,
132 + {
133 + "dir_first": {},
134 + "dir_second": {},
135 + "file_alpha.txt": "",
136 + "file_beta.txt": "",
137 + "file_gamma.txt": "",
138 + },
139 + )
140 + base_timestamp = time.time()
141 + for offset, name in enumerate(
142 + ["dir_first", "dir_second", "file_alpha.txt", "file_beta.txt", "file_gamma.txt"],
143 + start=1,
144 + ):
145 + _set_entry_times(os.path.join(base_rel, name), base_timestamp + offset)
146 +
147 + flat_results = {
148 + (key, direction): file_tree(
149 + base_rel,
150 + folders_first=False,
151 + sort=(key, direction),
152 + output_mode=OUTPUT_MODE_FLAT,
153 + )
154 + for key in (SORT_BY_NAME, SORT_BY_CREATED, SORT_BY_MODIFIED)
155 + for direction in (SORT_ASC, SORT_DESC)
156 + }
157 +
158 + for (key, direction), items in flat_results.items():
159 + filtered = [item for item in items if item["type"] != "comment"]
160 + folders = [item for item in filtered if item["type"] == "folder"]
161 + files = [item for item in filtered if item["type"] == "file"]
162 +
163 + if key == SORT_BY_NAME:
164 + file_names = [item["name"] for item in files]
165 + folder_names = [item["name"] for item in folders]
166 + assert file_names == sorted(file_names, reverse=direction == SORT_DESC)
167 + assert folder_names == sorted(folder_names, reverse=direction == SORT_DESC)
168 + elif key == SORT_BY_CREATED:
169 + file_created = [item["created"] for item in files]
170 + folder_created = [item["created"] for item in folders]
171 + _assert_datetime_order(file_created, direction)
172 + _assert_datetime_order(folder_created, direction)
173 + else:
174 + file_modified = [item["modified"] for item in files]
175 + folder_modified = [item["modified"] for item in folders]
176 + _assert_datetime_order(file_modified, direction)
177 + _assert_datetime_order(folder_modified, direction)
178 +
179 +
180 +def test_file_tree_nested_structure_and_ignore() -> None:
181 + base_rel = "tmp/tests/file_tree/nested_structure"
182 + with _project_directory(base_rel):
183 + _materialize_structure(
184 + base_rel,
185 + {
186 + "src": {
187 + "a.py": "",
188 + "b.py": "",
189 + "cache": {
190 + "ignored.py": "",
191 + "keep.py": "",
192 + },
193 + },
194 + "README.md": "",
195 + },
196 + )
197 +
198 + nested = file_tree(
199 + base_rel,
200 + folders_first=True,
201 + sort=(SORT_BY_NAME, SORT_ASC),
202 + ignore="cache/\n!src/cache/keep.py",
203 + output_mode=OUTPUT_MODE_NESTED,
204 + )
205 +
206 + assert isinstance(nested, list)
207 + src_node = next(node for node in nested if node["name"] == "src")
208 + src_children = {child["name"] for child in src_node["items"]}
209 + assert "cache" in src_children # cache appears to host keep.py
210 +
211 + cache_node = next(child for child in src_node["items"] if child["name"] == "cache")
212 + cache_children = {child["name"] for child in cache_node["items"]}
213 + assert "keep.py" in cache_children
214 + assert "ignored.py" not in cache_children
215 + assert [child["name"] for child in src_node["items"]] == ["cache", "a.py", "b.py"]
216 +
217 +
218 +def test_file_tree_nested_sort_all_keys() -> None:
219 + base_rel = "tmp/tests/file_tree/nested_sort_all_keys"
220 + with _project_directory(base_rel):
221 + _materialize_structure(
222 + base_rel,
223 + {
224 + "dir_first": {
225 + "inner_a.txt": "",
226 + "inner_b.txt": "",
227 + },
228 + "dir_second": {
229 + "nested_dir": {
230 + "deep_file.txt": "",
231 + },
232 + },
233 + "file_alpha.txt": "",
234 + },
235 + )
236 + base_timestamp = time.time()
237 + for offset, rel_path in enumerate(
238 + [
239 + "dir_first",
240 + os.path.join("dir_first", "inner_a.txt"),
241 + os.path.join("dir_first", "inner_b.txt"),
242 + "dir_second",
243 + os.path.join("dir_second", "nested_dir"),
244 + os.path.join("dir_second", "nested_dir", "deep_file.txt"),
245 + "file_alpha.txt",
246 + ],
247 + start=1,
248 + ):
249 + _set_entry_times(os.path.join(base_rel, rel_path), base_timestamp + offset)
250 +
251 + nested_results = {
252 + (key, direction): file_tree(
253 + base_rel,
254 + folders_first=False,
255 + sort=(key, direction),
256 + output_mode=OUTPUT_MODE_NESTED,
257 + )
258 + for key in (SORT_BY_NAME, SORT_BY_CREATED, SORT_BY_MODIFIED)
259 + for direction in (SORT_ASC, SORT_DESC)
260 + }
261 +
262 + def assert_nested_sorted(items: list[dict], key: str, direction: str) -> None:
263 + filtered = [item for item in items if item["type"] != "comment"]
264 + if not filtered:
265 + return
266 + folders = [item for item in filtered if item["type"] == "folder"]
267 + files = [item for item in filtered if item["type"] == "file"]
268 +
269 + def assert_group(group: list[dict], attr: str) -> None:
270 + values = [entry[attr] for entry in group]
271 + if attr == "name":
272 + assert values == sorted(values, reverse=direction == SORT_DESC)
273 + else:
274 + _assert_datetime_order(values, direction)
275 +
276 + if key == SORT_BY_NAME:
277 + assert_group(files, "name")
278 + assert_group(folders, "name")
279 + elif key == SORT_BY_CREATED:
280 + assert_group(files, "created")
281 + assert_group(folders, "created")
282 + else:
283 + assert_group(files, "modified")
284 + assert_group(folders, "modified")
285 +
286 + for child in folders + files:
287 + if child.get("items"):
288 + assert_nested_sorted(child["items"], key, direction)
289 +
290 + for (key, direction), items in nested_results.items():
291 + assert_nested_sorted(items, key, direction)
292 +
293 +
294 +def test_file_tree_nested_respects_max_depth() -> None:
295 + base_rel = "tmp/tests/file_tree/nested_max_depth"
296 + with _project_directory(base_rel):
297 + _materialize_structure(
298 + base_rel,
299 + {
300 + "top": {
301 + "branch": {
302 + "leaf.txt": "",
303 + },
304 + },
305 + },
306 + )
307 +
308 + nested = file_tree(
309 + base_rel,
310 + folders_first=True,
311 + sort=(SORT_BY_NAME, SORT_ASC),
312 + max_depth=1,
313 + output_mode=OUTPUT_MODE_NESTED,
314 + )
315 +
316 + top_node = nested[0]
317 + assert top_node["items"] == [] # depth limited, children omitted
318 +
319 +
320 +def test_file_tree_flat_and_nested_timestamps_match_filesystem() -> None:
321 + base_rel = "tmp/tests/file_tree/timestamp_accuracy"
322 + with _project_directory(base_rel) as base_abs:
323 + _materialize_structure(
324 + base_rel,
325 + {
326 + "folder_root": {
327 + "folder_branch": {
328 + "leaf_unique.txt": "",
329 + },
330 + "leaf_second.txt": "",
331 + },
332 + "file_standalone.txt": "",
333 + },
334 + )
335 +
336 + expected_stats = _collect_expected_stats(base_abs)
337 + flat = file_tree(
338 + base_rel,
339 + folders_first=True,
340 + sort=(SORT_BY_NAME, SORT_ASC),
341 + output_mode=OUTPUT_MODE_FLAT,
342 + )
343 + nested = file_tree(
344 + base_rel,
345 + folders_first=True,
346 + sort=(SORT_BY_NAME, SORT_ASC),
347 + output_mode=OUTPUT_MODE_NESTED,
348 + )
349 +
350 + def assert_matches(item: dict) -> None:
351 + if item["type"] == "comment":
352 + return
353 + created, modified = expected_stats[item["name"]]
354 + assert item["created"] == created
355 + assert item["modified"] == modified
356 +
357 + for entry in flat:
358 + assert_matches(entry)
359 +
360 + for entry in _flatten_nested(nested):
361 + assert_matches(entry)
362 +
363 +
364 +def test_file_tree_nested_summary_comments() -> None:
365 + base_rel = "tmp/tests/file_tree/nested_summary_comments"
366 + with _project_directory(base_rel):
367 + _materialize_structure(
368 + base_rel,
369 + {
370 + "folder_root": {
371 + "keep_me.txt": "",
372 + "omit_me.txt": "",
373 + "omit_me_too.txt": "",
374 + }
375 + },
376 + )
377 + base_timestamp = time.time()
378 + _set_entry_times(os.path.join(base_rel, "folder_root/keep_me.txt"), base_timestamp + 3)
379 + _set_entry_times(os.path.join(base_rel, "folder_root/omit_me.txt"), base_timestamp + 1)
380 + _set_entry_times(os.path.join(base_rel, "folder_root/omit_me_too.txt"), base_timestamp + 2)
381 +
382 + nested = file_tree(
383 + base_rel,
384 + folders_first=True,
385 + max_files=1,
386 + output_mode=OUTPUT_MODE_NESTED,
387 + )
388 +
389 + folder_node = nested[0]
390 + assert folder_node["name"] == "folder_root"
391 + child_items = folder_node["items"]
392 + assert child_items is not None
393 + comment_nodes = [item for item in child_items if item["type"] == "comment"]
394 + assert comment_nodes, "Expected summary comment for truncated children"
395 + comment_label = comment_nodes[0]["text"].split("── ", 1)[-1]
396 + assert comment_label.startswith("# ")
397 + file_names = [item["name"] for item in child_items if item["type"] == "file"]
398 + assert file_names == ["keep_me.txt"]
399 + assert comment_nodes[0] is child_items[-1]
400 + assert comment_nodes[0]["items"] is None
401 +
402 +
403 +def test_file_tree_flat_files_first_limits() -> None:
404 + base_rel = "tmp/tests/file_tree/flat_files_first_limits"
405 + with _project_directory(base_rel):
406 + _materialize_structure(
407 + base_rel,
408 + {
409 + "dir1": {},
410 + "dir2": {},
411 + "dir3": {},
412 + "dir4": {},
413 + "a.txt": "",
414 + "b.txt": "",
415 + "c.txt": "",
416 + },
417 + )
418 +
419 + flat = file_tree(
420 + base_rel,
421 + folders_first=False,
422 + max_folders=1,
423 + max_files=1,
424 + sort=(SORT_BY_NAME, SORT_ASC),
425 + output_mode=OUTPUT_MODE_FLAT,
426 + )
427 +
428 + comment_nodes = [item for item in flat if item["type"] == "comment"]
429 + assert {node["name"] for node in comment_nodes} == {"3 more folders", "2 more files"}
430 + assert flat[0]["type"] == "file" # files come first when folders_first=False
431 +
432 +
433 +def test_file_tree_flat_sort_created_max_lines() -> None:
434 + base_rel = "tmp/tests/file_tree/flat_sort_created_max_lines"
435 + with _project_directory(base_rel):
436 + _materialize_structure(
437 + base_rel,
438 + {
439 + "dirA": {"inner.txt": ""},
440 + "file1.txt": "",
441 + "file2.txt": "",
442 + "file3.txt": "",
443 + },
444 + )
445 + base_ts = time.time()
446 + for offset, rel_path in enumerate(
447 + [
448 + "dirA",
449 + os.path.join("dirA", "inner.txt"),
450 + "file1.txt",
451 + "file2.txt",
452 + "file3.txt",
453 + ],
454 + start=1,
455 + ):
456 + _set_entry_times(os.path.join(base_rel, rel_path), base_ts + offset)
457 +
458 + flat = file_tree(
459 + base_rel,
460 + folders_first=True,
461 + sort=(SORT_BY_CREATED, SORT_DESC),
462 + max_lines=4,
463 + output_mode=OUTPUT_MODE_FLAT,
464 + )
465 +
466 + assert len(flat) == 4
467 + assert flat[0]["name"] == "dirA"
468 + file_names = [item["name"] for item in flat if item["type"] == "file"]
469 + assert file_names == ["file3.txt", "file2.txt", "file1.txt"]
470 +
471 +
472 +def test_file_tree_nested_files_first_limits() -> None:
473 + base_rel = "tmp/tests/file_tree/nested_files_first_limits"
474 + with _project_directory(base_rel):
475 + _materialize_structure(
476 + base_rel,
477 + {
478 + "dir": {
479 + "a.py": "",
480 + "b.py": "",
481 + "c.py": "",
482 + },
483 + "folder_a": {"inner.txt": ""},
484 + "folder_b": {},
485 + "folder_c": {},
486 + },
487 + )
488 +
489 + nested = file_tree(
490 + base_rel,
491 + folders_first=False,
492 + max_folders=1,
493 + max_files=1,
494 + sort=(SORT_BY_NAME, SORT_ASC),
495 + output_mode=OUTPUT_MODE_NESTED,
496 + )
497 +
498 + root = nested
499 + comment_nodes = [item for item in root if item["type"] == "comment"]
500 + assert comment_nodes
501 + assert any("more folders" in node["text"] for node in comment_nodes)
502 + all_nodes = _flatten_nested(root)
503 + file_comment_nodes = [item for item in all_nodes if item["type"] == "comment" and "more files" in item["text"]]
504 + assert file_comment_nodes, "Expected file summary comment within nested structure"
505 +
506 +
507 +def test_file_tree_nested_max_depth_with_sort() -> None:
508 + base_rel = "tmp/tests/file_tree/nested_max_depth_sort"
509 + with _project_directory(base_rel):
510 + _materialize_structure(
511 + base_rel,
512 + {
513 + "root": {
514 + "branch": {
515 + "leaf_a.txt": "",
516 + "leaf_b.txt": "",
517 + },
518 + },
519 + "alpha.txt": "",
520 + },
521 + )
522 + base_ts = time.time()
523 + for offset, rel_path in enumerate(
524 + [
525 + "root",
526 + os.path.join("root", "branch"),
527 + os.path.join("root", "branch", "leaf_a.txt"),
528 + os.path.join("root", "branch", "leaf_b.txt"),
529 + "alpha.txt",
530 + ],
531 + start=1,
532 + ):
533 + _set_entry_times(os.path.join(base_rel, rel_path), base_ts + offset)
534 +
535 + nested = file_tree(
536 + base_rel,
537 + folders_first=True,
538 + sort=(SORT_BY_CREATED, SORT_ASC),
539 + max_depth=2,
540 + output_mode=OUTPUT_MODE_NESTED,
541 + )
542 +
543 + root_node = next(item for item in nested if item["name"] == "root")
544 + assert root_node["items"] is not None
545 + branch_node = next(child for child in root_node["items"] if child["name"] == "branch")
546 + assert branch_node["items"] == []
547 +
548 +
549 +def test_file_tree_nested_global_max_lines_prunes_depth() -> None:
550 + base_rel = "tmp/tests/file_tree/nested_max_lines"
551 + with _project_directory(base_rel):
552 + _materialize_structure(
553 + base_rel,
554 + {
555 + "dirA": {
556 + "a1.txt": "",
557 + "a2.txt": "",
558 + },
559 + "dirB": {
560 + "b1.txt": "",
561 + "b2.txt": "",
562 + },
563 + "root_file.txt": "",
564 + },
565 + )
566 +
567 + nested = file_tree(
568 + base_rel,
569 + folders_first=True,
570 + max_lines=4,
571 + output_mode=OUTPUT_MODE_NESTED,
572 + )
573 +
574 + flattened = _flatten_nested(nested)
575 + levels = [item["level"] for item in flattened]
576 + assert all(level <= 2 for level in levels)
577 + top_level = [item for item in flattened if item["level"] == 1]
578 + assert {item["name"] for item in top_level} == {"dirA", "dirB", "root_file.txt"}
tests/test_file_tree_visualize.py new
+680
@@ -0,0 +1,680 @@
1 +from __future__ import annotations
2 +
3 +import argparse
4 +import os
5 +from collections.abc import Iterable
6 +from contextlib import contextmanager
7 +from dataclasses import dataclass, field
8 +from pathlib import Path
9 +import sys
10 +import time
11 +from typing import Any, Callable, Dict, List, Optional
12 +
13 +try:
14 + import pytest # type: ignore
15 +except ImportError: # pragma: no cover
16 + pytest = None
17 +
18 +if pytest is not None:
19 + pytestmark = pytest.mark.skip(reason="Visualization utility; excluded from automated test runs.")
20 +
21 +
22 +REPO_ROOT = Path(__file__).resolve().parents[1]
23 +if str(REPO_ROOT) not in sys.path:
24 + sys.path.insert(0, str(REPO_ROOT))
25 +
26 +from python.helpers.files import (
27 + OUTPUT_MODE_FLAT,
28 + OUTPUT_MODE_NESTED,
29 + OUTPUT_MODE_STRING,
30 + SORT_ASC,
31 + SORT_BY_CREATED,
32 + SORT_BY_MODIFIED,
33 + SORT_BY_NAME,
34 + SORT_DESC,
35 + create_dir,
36 + delete_dir,
37 + file_tree,
38 + get_abs_path,
39 + write_file,
40 +)
41 +
42 +
43 +BASE_TEMP_ROOT = "tmp/tests/file_tree/visualize"
44 +
45 +
46 +@dataclass(slots=True)
47 +class Config:
48 + label: str
49 + params: Dict[str, Any]
50 +
51 +
52 +SetupHook = Optional[Callable[[str], None]]
53 +
54 +
55 +@dataclass(slots=True)
56 +class Scenario:
57 + name: str
58 + description: str
59 + structure: Dict[str, Any]
60 + configs: List[Config] = field(default_factory=list)
61 + ignore_content: Optional[str] = None
62 + setup: SetupHook = None
63 +
64 +
65 +def materialize_structure(base_rel: str, structure: Dict[str, Any]) -> None:
66 + for entry, value in structure.items():
67 + rel = os.path.join(base_rel, entry)
68 + if isinstance(value, dict):
69 + create_dir(rel)
70 + materialize_structure(rel, value)
71 + else:
72 + write_file(rel, "" if value is None else str(value))
73 +
74 +
75 +def ensure_ignore_file(base_rel: str, content: str) -> None:
76 + write_file(os.path.join(base_rel, ".treeignore"), content.strip() + "\n")
77 +
78 +
79 +def print_header(title: str, char: str = "=") -> None:
80 + print(char * 80)
81 + print(title)
82 + print(char * 80)
83 +
84 +
85 +def print_flat(items: List[Dict[str, Any]]) -> None:
86 + print("level type name text")
87 + print("-" * 80)
88 + for item in items:
89 + level = item["level"]
90 + item_type = item["type"]
91 + name = item["name"]
92 + text = item["text"]
93 + print(f"{level:<5} {item_type:<7} {name:<20} {text}")
94 +
95 +
96 +def print_nested(items: List[Dict[str, Any]], root_label: str) -> None:
97 + print(root_label)
98 + for item in items:
99 + text = item["text"]
100 + print(f"{text} [{item['type']}]")
101 +
102 +
103 +@contextmanager
104 +def scenario_directory(name: str) -> Iterable[str]:
105 + rel_path = os.path.join(BASE_TEMP_ROOT, name)
106 + delete_dir(rel_path)
107 + create_dir(rel_path)
108 + try:
109 + yield rel_path
110 + finally:
111 + delete_dir(rel_path)
112 +
113 +
114 +def _set_entry_times(relative_path: str, timestamp: float) -> None:
115 + abs_path = get_abs_path(relative_path)
116 + os.utime(abs_path, (timestamp, timestamp))
117 + time.sleep(0.01)
118 +
119 +
120 +def _apply_timestamps(base_rel: str, paths: List[str], base_ts: Optional[float] = None) -> None:
121 + if base_ts is None:
122 + base_ts = time.time()
123 + for offset, rel in enumerate(paths, start=1):
124 + _set_entry_times(os.path.join(base_rel, rel), base_ts + offset)
125 +
126 +
127 +def list_scenarios(scenarios: List[Scenario]) -> None:
128 + print("Available scenarios:")
129 + for scenario in scenarios:
130 + print(f" - {scenario.name}: {scenario.description}")
131 +
132 +
133 +def run_scenarios(selected: List[Scenario]) -> None:
134 + create_dir(BASE_TEMP_ROOT)
135 + for scenario in selected:
136 + print_header(f"Scenario: {scenario.name} — {scenario.description}")
137 + with scenario_directory(scenario.name) as base_rel:
138 + materialize_structure(base_rel, scenario.structure)
139 +
140 + if scenario.ignore_content:
141 + ensure_ignore_file(base_rel, scenario.ignore_content)
142 +
143 + if scenario.setup:
144 + scenario.setup(base_rel)
145 +
146 + for config in scenario.configs:
147 + print_header(f"Configuration: {config.label}", "-")
148 + params = {
149 + "relative_path": base_rel,
150 + "max_depth": 0,
151 + "max_lines": 0,
152 + "folders_first": True,
153 + "max_folders": None,
154 + "max_files": None,
155 + "sort": (SORT_BY_MODIFIED, SORT_DESC),
156 + **config.params,
157 + }
158 + output_mode = params.setdefault("output_mode", OUTPUT_MODE_STRING)
159 + print("Parameters:")
160 + print(f" output_mode : {output_mode}")
161 + print(f" folders_first : {params['folders_first']}")
162 + sort_key, sort_dir = params["sort"]
163 + print(f" sort : key={sort_key}, direction={sort_dir}")
164 + print(f" max_depth : {params['max_depth']}")
165 + print(f" max_lines : {params['max_lines']}")
166 + print(f" max_folders : {params['max_folders']}")
167 + print(f" max_files : {params['max_files']}")
168 + print(f" ignore : {params.get('ignore')}")
169 + print()
170 + result = file_tree(**params)
171 +
172 + if output_mode == OUTPUT_MODE_STRING:
173 + print(result)
174 + elif output_mode == OUTPUT_MODE_FLAT:
175 + print_flat(result) # type: ignore[arg-type]
176 + elif output_mode == OUTPUT_MODE_NESTED:
177 + print_nested(result, f"{scenario.name}/")
178 + else:
179 + print(f"(Unhandled output mode {output_mode!r})")
180 +
181 + print()
182 +
183 +
184 +def build_scenarios() -> List[Scenario]:
185 + scenarios: List[Scenario] = []
186 +
187 + scenarios.append(
188 + Scenario(
189 + name="basic_breadth_first",
190 + description="Default breadth-first traversal with mixed folders/files",
191 + structure={
192 + "alpha": {"alpha_file.txt": "alpha", "nested": {"inner.txt": "inner"}},
193 + "beta": {"beta_file.txt": "beta"},
194 + "zeta": {},
195 + "a.txt": "A",
196 + "b.txt": "B",
197 + },
198 + configs=[
199 + Config(
200 + "string • folders-first (name asc)",
201 + {
202 + "output_mode": OUTPUT_MODE_STRING,
203 + "folders_first": True,
204 + "sort": (SORT_BY_NAME, SORT_ASC),
205 + },
206 + ),
207 + Config(
208 + "string • folders-first disabled",
209 + {
210 + "output_mode": OUTPUT_MODE_STRING,
211 + "folders_first": False,
212 + "sort": (SORT_BY_NAME, SORT_ASC),
213 + },
214 + ),
215 + Config(
216 + "flat • folders-first",
217 + {
218 + "output_mode": OUTPUT_MODE_FLAT,
219 + "folders_first": True,
220 + "sort": (SORT_BY_NAME, SORT_ASC),
221 + },
222 + ),
223 + Config(
224 + "nested • folders-first",
225 + {
226 + "output_mode": OUTPUT_MODE_NESTED,
227 + "folders_first": True,
228 + "sort": (SORT_BY_NAME, SORT_ASC),
229 + },
230 + ),
231 + ],
232 + )
233 + )
234 +
235 + def setup_sorting(base_rel: str) -> None:
236 + entries = [
237 + "folder_alpha",
238 + "folder_beta",
239 + "file_first.txt",
240 + "file_second.txt",
241 + "file_third.txt",
242 + ]
243 + for index, entry in enumerate(entries, start=1):
244 + abs_path = get_abs_path(os.path.join(base_rel, entry))
245 + timestamp = 200_000_0000 + index
246 + os.utime(abs_path, (timestamp, timestamp))
247 +
248 + scenarios.append(
249 + Scenario(
250 + name="sorting_variants",
251 + description="Demonstrate sorting by name and timestamp with folders/files",
252 + structure={
253 + "folder_alpha": {},
254 + "folder_beta": {},
255 + "file_first.txt": "",
256 + "file_second.txt": "",
257 + "file_third.txt": "",
258 + },
259 + configs=[
260 + Config(
261 + "string • sort by name asc",
262 + {
263 + "output_mode": OUTPUT_MODE_STRING,
264 + "folders_first": True,
265 + "sort": (SORT_BY_NAME, SORT_ASC),
266 + },
267 + ),
268 + Config(
269 + "string • sort by created desc",
270 + {
271 + "output_mode": OUTPUT_MODE_STRING,
272 + "folders_first": True,
273 + "sort": (SORT_BY_CREATED, SORT_DESC),
274 + },
275 + ),
276 + Config(
277 + "flat • sort by modified asc",
278 + {
279 + "output_mode": OUTPUT_MODE_FLAT,
280 + "folders_first": True,
281 + "sort": (SORT_BY_MODIFIED, SORT_ASC),
282 + },
283 + ),
284 + ],
285 + setup=setup_sorting,
286 + )
287 + )
288 +
289 + scenarios.append(
290 + Scenario(
291 + name="ignore_and_limits",
292 + description="Ignore file semantics with max_folders/max_files summaries",
293 + structure={
294 + "src": {
295 + "main.py": "print('hello')",
296 + "utils.py": "pass",
297 + "tmp.tmp": "",
298 + "cache": {"cached.txt": "", "keep.txt": ""},
299 + "modules": {"a.py": "", "b.py": "", "c.py": ""},
300 + "pkg": {"alpha.py": "", "beta.py": "", "gamma.py": ""},
301 + },
302 + "logs": {"2024.log": "", "2025.log": ""},
303 + "notes.md": "",
304 + "guide.md": "",
305 + "todo.md": "",
306 + "build.tmp": "",
307 + "archive": {},
308 + "assets": {},
309 + "sandbox": {},
310 + "vendor": {},
311 + },
312 + ignore_content="\n".join(
313 + ["*.tmp", "cache/", "!src/cache/keep.txt", "logs/", "!logs/2025.log"]
314 + ),
315 + configs=[
316 + Config(
317 + "string • folders-first with summaries",
318 + {
319 + "output_mode": OUTPUT_MODE_STRING,
320 + "folders_first": False,
321 + "sort": (SORT_BY_NAME, SORT_ASC),
322 + "max_folders": 1,
323 + "max_files": 2,
324 + "max_lines": 12,
325 + "ignore": "file:.treeignore",
326 + },
327 + ),
328 + Config(
329 + "nested • inspect truncated branches & comments",
330 + {
331 + "output_mode": OUTPUT_MODE_NESTED,
332 + "folders_first": False,
333 + "sort": (SORT_BY_NAME, SORT_ASC),
334 + "max_folders": 1,
335 + "max_files": 2,
336 + "max_lines": 12,
337 + "ignore": "file:.treeignore",
338 + },
339 + ),
340 + ],
341 + )
342 + )
343 +
344 + scenarios.append(
345 + Scenario(
346 + name="limits_exact_match",
347 + description="Per-directory limits exactly met (no summary comments)",
348 + structure={
349 + "pkg": {
350 + "a.py": "",
351 + "b.py": "",
352 + "dir1": {},
353 + "dir2": {},
354 + }
355 + },
356 + configs=[
357 + Config(
358 + "string • exact matches (no summaries)",
359 + {
360 + "output_mode": OUTPUT_MODE_STRING,
361 + "folders_first": True,
362 + "sort": (SORT_BY_NAME, SORT_ASC),
363 + "max_folders": 2,
364 + "max_files": 2,
365 + },
366 + ),
367 + Config(
368 + "flat • exact matches (no summaries)",
369 + {
370 + "output_mode": OUTPUT_MODE_FLAT,
371 + "folders_first": True,
372 + "sort": (SORT_BY_NAME, SORT_ASC),
373 + "max_folders": 2,
374 + "max_files": 2,
375 + },
376 + ),
377 + ],
378 + )
379 + )
380 +
381 + scenarios.append(
382 + Scenario(
383 + name="single_overflow",
384 + description="Single overflow entries promoted instead of summary comment",
385 + structure={
386 + "pkg": {
387 + "dir_a": {},
388 + "dir_b": {},
389 + "file_a.txt": "",
390 + }
391 + },
392 + configs=[
393 + Config(
394 + "string • single folder overflow",
395 + {
396 + "output_mode": OUTPUT_MODE_STRING,
397 + "folders_first": True,
398 + "sort": (SORT_BY_NAME, SORT_ASC),
399 + "max_folders": 1,
400 + },
401 + ),
402 + Config(
403 + "string • single file overflow",
404 + {
405 + "output_mode": OUTPUT_MODE_STRING,
406 + "folders_first": False,
407 + "sort": (SORT_BY_NAME, SORT_ASC),
408 + "max_files": 1,
409 + },
410 + ),
411 + Config(
412 + "flat • folders-first",
413 + {
414 + "output_mode": OUTPUT_MODE_FLAT,
415 + "folders_first": True,
416 + "sort": (SORT_BY_NAME, SORT_ASC),
417 + "max_folders": 1,
418 + },
419 + ),
420 + ],
421 + )
422 + )
423 +
424 + scenarios.append(
425 + Scenario(
426 + name="global_max_lines",
427 + description="Global max_lines finishing current depth before truncation",
428 + structure={
429 + "layer1_a": {
430 + "layer2_a": {
431 + "layer3_a": {
432 + "layer4_a": {"layer5_a.txt": ""},
433 + }
434 + }
435 + },
436 + "layer1_b": {
437 + "layer2_b": {
438 + "layer3_b": {
439 + "layer4_b": {"layer5_b.txt": ""},
440 + }
441 + }
442 + },
443 + "root_file.txt": "",
444 + },
445 + configs=[
446 + Config(
447 + "string • max_lines=6",
448 + {
449 + "output_mode": OUTPUT_MODE_STRING,
450 + "max_lines": 6,
451 + "sort": (SORT_BY_NAME, SORT_ASC),
452 + },
453 + ),
454 + Config(
455 + "nested • max_lines=6",
456 + {
457 + "output_mode": OUTPUT_MODE_NESTED,
458 + "max_lines": 6,
459 + "sort": (SORT_BY_NAME, SORT_ASC),
460 + },
461 + ),
462 + ],
463 + )
464 + )
465 +
466 + scenarios.append(
467 + Scenario(
468 + name="flat_files_first_limits",
469 + description="Flat output with files-first ordering and per-directory summaries",
470 + structure={
471 + "dir1": {},
472 + "dir2": {},
473 + "dir3": {},
474 + "dir4": {},
475 + "a.txt": "",
476 + "b.txt": "",
477 + "c.txt": "",
478 + },
479 + configs=[
480 + Config(
481 + "flat • files-first with limits",
482 + {
483 + "output_mode": OUTPUT_MODE_FLAT,
484 + "folders_first": False,
485 + "sort": (SORT_BY_NAME, SORT_ASC),
486 + "max_folders": 1,
487 + "max_files": 1,
488 + },
489 + )
490 + ],
491 + )
492 + )
493 +
494 + scenarios.append(
495 + Scenario(
496 + name="flat_sort_created_max_lines",
497 + description="Flat output sorted by created time with global max_lines",
498 + structure={
499 + "dirA": {"inner.txt": ""},
500 + "file1.txt": "",
501 + "file2.txt": "",
502 + "file3.txt": "",
503 + },
504 + setup=lambda base_rel: _apply_timestamps(
505 + base_rel,
506 + [
507 + "dirA",
508 + os.path.join("dirA", "inner.txt"),
509 + "file1.txt",
510 + "file2.txt",
511 + "file3.txt",
512 + ],
513 + base_ts=2_000_001_000,
514 + ),
515 + configs=[
516 + Config(
517 + "flat • sort by created desc, max_lines=4",
518 + {
519 + "output_mode": OUTPUT_MODE_FLAT,
520 + "folders_first": True,
521 + "sort": (SORT_BY_CREATED, SORT_DESC),
522 + "max_lines": 4,
523 + },
524 + )
525 + ],
526 + )
527 + )
528 +
529 + scenarios.append(
530 + Scenario(
531 + name="nested_files_first_limits",
532 + description="Nested output with files-first ordering and per-directory summaries",
533 + structure={
534 + "dir": {"a.py": "", "b.py": "", "c.py": ""},
535 + "folder_a": {"inner.txt": ""},
536 + "folder_b": {},
537 + "folder_c": {},
538 + },
539 + configs=[
540 + Config(
541 + "nested • files-first with limits",
542 + {
543 + "output_mode": OUTPUT_MODE_NESTED,
544 + "folders_first": False,
545 + "sort": (SORT_BY_NAME, SORT_ASC),
546 + "max_folders": 1,
547 + "max_files": 1,
548 + },
549 + )
550 + ],
551 + )
552 + )
553 +
554 + scenarios.append(
555 + Scenario(
556 + name="nested_max_depth_sort",
557 + description="Nested output with created-time ordering and depth pruning",
558 + structure={
559 + "root": {
560 + "branch": {
561 + "leaf_a.txt": "",
562 + "leaf_b.txt": "",
563 + }
564 + },
565 + "alpha.txt": "",
566 + },
567 + setup=lambda base_rel: _apply_timestamps(
568 + base_rel,
569 + [
570 + "root",
571 + os.path.join("root", "branch"),
572 + os.path.join("root", "branch", "leaf_a.txt"),
573 + os.path.join("root", "branch", "leaf_b.txt"),
574 + "alpha.txt",
575 + ],
576 + base_ts=2_000_010_000,
577 + ),
578 + configs=[
579 + Config(
580 + "nested • sort by created asc, max_depth=2",
581 + {
582 + "output_mode": OUTPUT_MODE_NESTED,
583 + "folders_first": True,
584 + "sort": (SORT_BY_CREATED, SORT_ASC),
585 + "max_depth": 2,
586 + },
587 + )
588 + ],
589 + )
590 + )
591 +
592 + scenarios.append(
593 + Scenario(
594 + name="string_additional_limits",
595 + description="String output exercising files-first+max_lines and zero-limit semantics",
596 + structure={
597 + "dir": {"inner_a.txt": "", "inner_b.txt": ""},
598 + "alpha.txt": "",
599 + "beta.txt": "",
600 + "gamma.txt": "",
601 + },
602 + setup=lambda base_rel: _apply_timestamps(
603 + base_rel,
604 + [
605 + "dir",
606 + os.path.join("dir", "inner_a.txt"),
607 + os.path.join("dir", "inner_b.txt"),
608 + "alpha.txt",
609 + "beta.txt",
610 + "gamma.txt",
611 + ],
612 + base_ts=2_000_020_000,
613 + ),
614 + configs=[
615 + Config(
616 + "string • files-first, sort=modified desc, max_lines=4",
617 + {
618 + "output_mode": OUTPUT_MODE_STRING,
619 + "folders_first": False,
620 + "sort": (SORT_BY_MODIFIED, SORT_DESC),
621 + "max_lines": 4,
622 + },
623 + ),
624 + Config(
625 + "string • zero file limit acts unlimited",
626 + {
627 + "output_mode": OUTPUT_MODE_STRING,
628 + "folders_first": True,
629 + "sort": (SORT_BY_NAME, SORT_ASC),
630 + "max_folders": 2,
631 + "max_files": 0,
632 + },
633 + ),
634 + ],
635 + )
636 + )
637 +
638 + return scenarios
639 +
640 +
641 +def parse_args() -> argparse.Namespace:
642 + parser = argparse.ArgumentParser(
643 + description="Visualize file_tree() outputs across configurations."
644 + )
645 + parser.add_argument(
646 + "--scenario",
647 + action="append",
648 + dest="scenarios",
649 + help="Scenario name to run (repeat for multiple). Default: run all.",
650 + )
651 + parser.add_argument(
652 + "--list",
653 + action="store_true",
654 + help="List available scenarios and exit.",
655 + )
656 + return parser.parse_args()
657 +
658 +
659 +def main() -> None:
660 + scenarios = build_scenarios()
661 + args = parse_args()
662 +
663 + if args.list:
664 + list_scenarios(scenarios)
665 + return
666 +
667 + if args.scenarios:
668 + name_map = {scenario.name: scenario for scenario in scenarios}
669 + unknown = [name for name in args.scenarios if name not in name_map]
670 + if unknown:
671 + raise SystemExit(f"Unknown scenario(s): {', '.join(unknown)}")
672 + selected = [name_map[name] for name in args.scenarios]
673 + else:
674 + selected = scenarios
675 +
676 + run_scenarios(selected)
677 +
678 +
679 +if __name__ == "__main__":
680 + main()
tests/test_file_tree_visualize.short.png
Binary files /dev/null and b/tests/test_file_tree_visualize.short.png differ
tests/test_file_tree_visualize.short.txt new
+341
@@ -0,0 +1,341 @@
1 +================================================================================
2 +Scenario: basic_breadth_first — Default breadth-first traversal with mixed folders/files
3 +================================================================================
4 +--------------------------------------------------------------------------------
5 +Configuration: string • folders-first (name asc)
6 +--------------------------------------------------------------------------------
7 +Parameters:
8 + output_mode : string
9 + folders_first : True
10 + sort : key=name, direction=asc
11 + max_depth : 0
12 + max_lines : 0
13 + max_folders : None
14 + max_files : None
15 + ignore : None
16 +
17 +tmp/tests/file_tree/visualize/basic_breadth_first/
18 +├── alpha/
19 +│ ├── nested/
20 +│ │ └── inner.txt
21 +│ └── alpha_file.txt
22 +├── beta/
23 +│ └── beta_file.txt
24 +├── zeta/
25 +├── a.txt
26 +└── b.txt
27 +--------------------------------------------------------------------------------
28 +Configuration: string • folders-first disabled
29 +--------------------------------------------------------------------------------
30 +Parameters:
31 + output_mode : string
32 + folders_first : False
33 + sort : key=name, direction=asc
34 + max_depth : 0
35 + max_lines : 0
36 + max_folders : None
37 + max_files : None
38 + ignore : None
39 +
40 +tmp/tests/file_tree/visualize/basic_breadth_first/
41 +├── a.txt
42 +├── b.txt
43 +├── alpha/
44 +│ ├── alpha_file.txt
45 +│ └── nested/
46 +│ └── inner.txt
47 +├── beta/
48 +│ └── beta_file.txt
49 +└── zeta/
50 +--------------------------------------------------------------------------------
51 +Configuration: flat • folders-first
52 +--------------------------------------------------------------------------------
53 +Parameters:
54 + output_mode : flat
55 + folders_first : True
56 + sort : key=name, direction=asc
57 + max_depth : 0
58 + max_lines : 0
59 + max_folders : None
60 + max_files : None
61 + ignore : None
62 +
63 +level type name text
64 +--------------------------------------------------------------------------------
65 +1 folder alpha ├── alpha/
66 +2 folder nested │ ├── nested/
67 +3 file inner.txt │ │ └── inner.txt
68 +2 file alpha_file.txt │ └── alpha_file.txt
69 +1 folder beta ├── beta/
70 +2 file beta_file.txt │ └── beta_file.txt
71 +1 folder zeta ├── zeta/
72 +1 file a.txt ├── a.txt
73 +1 file b.txt └── b.txt
74 +--------------------------------------------------------------------------------
75 +Configuration: nested • folders-first
76 +--------------------------------------------------------------------------------
77 +Parameters:
78 + output_mode : nested
79 + folders_first : True
80 + sort : key=name, direction=asc
81 + max_depth : 0
82 + max_lines : 0
83 + max_folders : None
84 + max_files : None
85 + ignore : None
86 +
87 +basic_breadth_first/
88 +├── alpha/ [folder]
89 +├── beta/ [folder]
90 +├── zeta/ [folder]
91 +├── a.txt [file]
92 +└── b.txt [file]
93 +
94 +================================================================================
95 +Scenario: sorting_variants — Demonstrate sorting by name and timestamp with folders/files
96 +================================================================================
97 +--------------------------------------------------------------------------------
98 +Configuration: string • sort by name asc
99 +--------------------------------------------------------------------------------
100 +Parameters:
101 + output_mode : string
102 + folders_first : True
103 + sort : key=name, direction=asc
104 + max_depth : 0
105 + max_lines : 0
106 + max_folders : None
107 + max_files : None
108 + ignore : None
109 +
110 +tmp/tests/file_tree/visualize/sorting_variants/
111 +├── folder_alpha/
112 +├── folder_beta/
113 +├── file_first.txt
114 +├── file_second.txt
115 +└── file_third.txt
116 +--------------------------------------------------------------------------------
117 +Configuration: string • sort by created desc
118 +--------------------------------------------------------------------------------
119 +Parameters:
120 + output_mode : string
121 + folders_first : True
122 + sort : key=created, direction=desc
123 + max_depth : 0
124 + max_lines : 0
125 + max_folders : None
126 + max_files : None
127 + ignore : None
128 +
129 +tmp/tests/file_tree/visualize/sorting_variants/
130 +├── folder_alpha/
131 +├── folder_beta/
132 +├── file_third.txt
133 +├── file_first.txt
134 +└── file_second.txt
135 +--------------------------------------------------------------------------------
136 +Configuration: flat • sort by modified asc
137 +--------------------------------------------------------------------------------
138 +Parameters:
139 + output_mode : flat
140 + folders_first : True
141 + sort : key=modified, direction=asc
142 + max_depth : 0
143 + max_lines : 0
144 + max_folders : None
145 + max_files : None
146 + ignore : None
147 +
148 +level type name text
149 +--------------------------------------------------------------------------------
150 +1 folder folder_alpha ├── folder_alpha/
151 +1 folder folder_beta ├── folder_beta/
152 +1 file file_first.txt ├── file_first.txt
153 +1 file file_second.txt ├── file_second.txt
154 +1 file file_third.txt └── file_third.txt
155 +
156 +================================================================================
157 +Scenario: ignore_and_limits — Ignore file semantics with max_folders/max_files summaries
158 +================================================================================
159 +--------------------------------------------------------------------------------
160 +Configuration: string • folders-first with summaries
161 +--------------------------------------------------------------------------------
162 +Parameters:
163 + output_mode : string
164 + folders_first : False
165 + sort : key=name, direction=asc
166 + max_depth : 0
167 + max_lines : 12
168 + max_folders : 1
169 + max_files : 2
170 + ignore : file:.treeignore
171 +
172 +tmp/tests/file_tree/visualize/ignore_and_limits/
173 +├── .treeignore
174 +├── guide.md
175 +├── # 2 more files
176 +├── archive/
177 +└── # 5 more folders
178 +--------------------------------------------------------------------------------
179 +Configuration: nested • inspect truncated branches & comments
180 +--------------------------------------------------------------------------------
181 +Parameters:
182 + output_mode : nested
183 + folders_first : False
184 + sort : key=name, direction=asc
185 + max_depth : 0
186 + max_lines : 12
187 + max_folders : 1
188 + max_files : 2
189 + ignore : file:.treeignore
190 +
191 +ignore_and_limits/
192 +├── .treeignore [file]
193 +├── guide.md [file]
194 +├── # 2 more files [comment]
195 +├── archive/ [folder]
196 +└── # 5 more folders [comment]
197 +
198 +================================================================================
199 +Scenario: limits_exact_match — Per-directory limits exactly met (no summary comments)
200 +================================================================================
201 +--------------------------------------------------------------------------------
202 +Configuration: string • exact matches (no summaries)
203 +--------------------------------------------------------------------------------
204 +Parameters:
205 + output_mode : string
206 + folders_first : True
207 + sort : key=name, direction=asc
208 + max_depth : 0
209 + max_lines : 0
210 + max_folders : 2
211 + max_files : 2
212 + ignore : None
213 +
214 +tmp/tests/file_tree/visualize/limits_exact_match/
215 +└── pkg/
216 + ├── dir1/
217 + ├── dir2/
218 + ├── a.py
219 + └── b.py
220 +--------------------------------------------------------------------------------
221 +Configuration: flat • exact matches (no summaries)
222 +--------------------------------------------------------------------------------
223 +Parameters:
224 + output_mode : flat
225 + folders_first : True
226 + sort : key=name, direction=asc
227 + max_depth : 0
228 + max_lines : 0
229 + max_folders : 2
230 + max_files : 2
231 + ignore : None
232 +
233 +level type name text
234 +--------------------------------------------------------------------------------
235 +1 folder pkg └── pkg/
236 +2 folder dir1 ├── dir1/
237 +2 folder dir2 ├── dir2/
238 +2 file a.py ├── a.py
239 +2 file b.py └── b.py
240 +
241 +================================================================================
242 +Scenario: single_overflow — Single overflow entries promoted instead of summary comment
243 +================================================================================
244 +--------------------------------------------------------------------------------
245 +Configuration: string • single folder overflow
246 +--------------------------------------------------------------------------------
247 +Parameters:
248 + output_mode : string
249 + folders_first : True
250 + sort : key=name, direction=asc
251 + max_depth : 0
252 + max_lines : 0
253 + max_folders : 1
254 + max_files : None
255 + ignore : None
256 +
257 +tmp/tests/file_tree/visualize/single_overflow/
258 +└── pkg/
259 + ├── dir_a/
260 + ├── dir_b/
261 + └── file_a.txt
262 +--------------------------------------------------------------------------------
263 +Configuration: string • single file overflow
264 +--------------------------------------------------------------------------------
265 +Parameters:
266 + output_mode : string
267 + folders_first : False
268 + sort : key=name, direction=asc
269 + max_depth : 0
270 + max_lines : 0
271 + max_folders : None
272 + max_files : 1
273 + ignore : None
274 +
275 +tmp/tests/file_tree/visualize/single_overflow/
276 +└── pkg/
277 + ├── file_a.txt
278 + ├── dir_a/
279 + └── dir_b/
280 +--------------------------------------------------------------------------------
281 +Configuration: flat • folders-first
282 +--------------------------------------------------------------------------------
283 +Parameters:
284 + output_mode : flat
285 + folders_first : True
286 + sort : key=name, direction=asc
287 + max_depth : 0
288 + max_lines : 0
289 + max_folders : 1
290 + max_files : None
291 + ignore : None
292 +
293 +level type name text
294 +--------------------------------------------------------------------------------
295 +1 folder pkg └── pkg/
296 +2 folder dir_a ├── dir_a/
297 +2 folder dir_b ├── dir_b/
298 +2 file file_a.txt └── file_a.txt
299 +
300 +================================================================================
301 +Scenario: global_max_lines — Global max_lines finishing current depth before truncation
302 +================================================================================
303 +--------------------------------------------------------------------------------
304 +Configuration: string • max_lines=6
305 +--------------------------------------------------------------------------------
306 +Parameters:
307 + output_mode : string
308 + folders_first : True
309 + sort : key=name, direction=asc
310 + max_depth : 0
311 + max_lines : 6
312 + max_folders : None
313 + max_files : None
314 + ignore : None
315 +
316 +tmp/tests/file_tree/visualize/global_max_lines/
317 +├── layer1_a/
318 +│ └── layer2_a/
319 +│ └── layer3_a/
320 +├── layer1_b/
321 +│ └── layer2_b/
322 +│ └── layer3_b/
323 +└── root_file.txt
324 +--------------------------------------------------------------------------------
325 +Configuration: nested • max_lines=6
326 +--------------------------------------------------------------------------------
327 +Parameters:
328 + output_mode : nested
329 + folders_first : True
330 + sort : key=name, direction=asc
331 + max_depth : 0
332 + max_lines : 6
333 + max_folders : None
334 + max_files : None
335 + ignore : None
336 +
337 +global_max_lines/
338 +├── layer1_a/ [folder]
339 +├── layer1_b/ [folder]
340 +└── root_file.txt [file]
341 +