File Tree: remove excessive testing code and the specifiction documents after finished impl

Rafael Uzarowski committed Nov 9, 2025 at 21:30 UTC 761fad5de56e1d33c02243c42dc8710b2bb172f1
15 files changed -2261
specs/001-file-tree-utility/checklists/requirements.md deleted
-35
@@ -1,35 +0,0 @@
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 deleted
-103
@@ -1,103 +0,0 @@
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 deleted
-49
@@ -1,49 +0,0 @@
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 deleted
-83
@@ -1,83 +0,0 @@
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 deleted
-101
@@ -1,101 +0,0 @@
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 deleted
-37
@@ -1,37 +0,0 @@
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 deleted
-193
@@ -1,193 +0,0 @@
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 deleted
-152
@@ -1,152 +0,0 @@
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 deleted
-92
@@ -1,92 +0,0 @@
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 deleted
-10
@@ -1,10 +0,0 @@
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 deleted
-14
@@ -1,14 +0,0 @@
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 deleted
-235
@@ -1,235 +0,0 @@
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 deleted
-86
@@ -1,86 +0,0 @@
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 deleted
-493
@@ -1,493 +0,0 @@
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 deleted
-578
@@ -1,578 +0,0 @@
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"}