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
+