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"}