llm-tool-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. llm_tool_cli/__init__.py +0 -0
  2. llm_tool_cli/cli/__init__.py +1 -0
  3. llm_tool_cli/cli/application.py +31 -0
  4. llm_tool_cli/cli/commands/__init__.py +0 -0
  5. llm_tool_cli/cli/commands/initialization.py +36 -0
  6. llm_tool_cli/cli/commands/skills.py +25 -0
  7. llm_tool_cli/cli/commands/tests/__init__.py +0 -0
  8. llm_tool_cli/cli/commands/tests/test_initialization.py +130 -0
  9. llm_tool_cli/cli/commands/tests/test_skills.py +154 -0
  10. llm_tool_cli/cli/commands/tests/test_version.py +98 -0
  11. llm_tool_cli/cli/commands/version.py +17 -0
  12. llm_tool_cli/cli/context.py +39 -0
  13. llm_tool_cli/cli/entities.py +3 -0
  14. llm_tool_cli/cli/errors.py +15 -0
  15. llm_tool_cli/cli/handling.py +41 -0
  16. llm_tool_cli/cli/options.py +48 -0
  17. llm_tool_cli/cli/tests/__init__.py +0 -0
  18. llm_tool_cli/cli/tests/test_application.py +167 -0
  19. llm_tool_cli/cli/tests/test_context.py +179 -0
  20. llm_tool_cli/cli/tests/test_handling.py +230 -0
  21. llm_tool_cli/cli/tests/test_options.py +125 -0
  22. llm_tool_cli/config/__init__.py +33 -0
  23. llm_tool_cli/config/errors.py +92 -0
  24. llm_tool_cli/config/fields.py +39 -0
  25. llm_tool_cli/config/files.py +175 -0
  26. llm_tool_cli/config/project.py +87 -0
  27. llm_tool_cli/config/runtime.py +14 -0
  28. llm_tool_cli/config/tests/__init__.py +0 -0
  29. llm_tool_cli/config/tests/fixtures.py +34 -0
  30. llm_tool_cli/config/tests/test_fields.py +124 -0
  31. llm_tool_cli/config/tests/test_files.py +826 -0
  32. llm_tool_cli/config/tests/test_project.py +257 -0
  33. llm_tool_cli/core/__init__.py +1 -0
  34. llm_tool_cli/core/context.py +50 -0
  35. llm_tool_cli/core/entities.py +35 -0
  36. llm_tool_cli/core/errors.py +97 -0
  37. llm_tool_cli/core/result.py +118 -0
  38. llm_tool_cli/core/settings.py +37 -0
  39. llm_tool_cli/core/tests/__init__.py +0 -0
  40. llm_tool_cli/core/tests/fixtures.py +13 -0
  41. llm_tool_cli/core/tests/test_context.py +152 -0
  42. llm_tool_cli/core/tests/test_entities.py +65 -0
  43. llm_tool_cli/core/tests/test_errors.py +211 -0
  44. llm_tool_cli/core/tests/test_result.py +256 -0
  45. llm_tool_cli/core/tests/test_settings.py +64 -0
  46. llm_tool_cli/output/__init__.py +3 -0
  47. llm_tool_cli/output/emitter.py +108 -0
  48. llm_tool_cli/output/errors.py +21 -0
  49. llm_tool_cli/output/journal.py +66 -0
  50. llm_tool_cli/output/tests/__init__.py +0 -0
  51. llm_tool_cli/output/tests/helpers.py +29 -0
  52. llm_tool_cli/output/tests/test_emitter.py +406 -0
  53. llm_tool_cli/output/tests/test_journal.py +141 -0
  54. llm_tool_cli/paths/__init__.py +51 -0
  55. llm_tool_cli/paths/enumeration.py +72 -0
  56. llm_tool_cli/paths/errors.py +42 -0
  57. llm_tool_cli/paths/filesystem.py +164 -0
  58. llm_tool_cli/paths/globs.py +124 -0
  59. llm_tool_cli/paths/normalization.py +58 -0
  60. llm_tool_cli/paths/tests/__init__.py +0 -0
  61. llm_tool_cli/paths/tests/test_enumeration.py +229 -0
  62. llm_tool_cli/paths/tests/test_filesystem.py +778 -0
  63. llm_tool_cli/paths/tests/test_globs.py +137 -0
  64. llm_tool_cli/paths/tests/test_normalization.py +163 -0
  65. llm_tool_cli/protocol/__init__.py +4 -0
  66. llm_tool_cli/protocol/cell_shortcuts.py +39 -0
  67. llm_tool_cli/protocol/entities.py +7 -0
  68. llm_tool_cli/protocol/errors.py +21 -0
  69. llm_tool_cli/protocol/journal.py +21 -0
  70. llm_tool_cli/protocol/logic_cells/__init__.py +4 -0
  71. llm_tool_cli/protocol/logic_cells/base.py +41 -0
  72. llm_tool_cli/protocol/logic_cells/content.py +26 -0
  73. llm_tool_cli/protocol/logic_cells/environment_error.py +36 -0
  74. llm_tool_cli/protocol/logic_cells/journal.py +44 -0
  75. llm_tool_cli/protocol/logic_cells/tests/__init__.py +0 -0
  76. llm_tool_cli/protocol/logic_cells/tests/test_base.py +47 -0
  77. llm_tool_cli/protocol/logic_cells/tests/test_content.py +59 -0
  78. llm_tool_cli/protocol/logic_cells/tests/test_environment_error.py +130 -0
  79. llm_tool_cli/protocol/logic_cells/tests/test_journal.py +47 -0
  80. llm_tool_cli/protocol/logic_cells/tests/test_uniform.py +60 -0
  81. llm_tool_cli/protocol/logic_cells/tests/test_warning.py +34 -0
  82. llm_tool_cli/protocol/logic_cells/uniform.py +21 -0
  83. llm_tool_cli/protocol/logic_cells/warning.py +22 -0
  84. llm_tool_cli/protocol/output_cells/__init__.py +11 -0
  85. llm_tool_cli/protocol/output_cells/automation.py +14 -0
  86. llm_tool_cli/protocol/output_cells/base.py +65 -0
  87. llm_tool_cli/protocol/output_cells/human.py +24 -0
  88. llm_tool_cli/protocol/output_cells/journal.py +23 -0
  89. llm_tool_cli/protocol/output_cells/llm.py +25 -0
  90. llm_tool_cli/protocol/output_cells/tests/__init__.py +0 -0
  91. llm_tool_cli/protocol/output_cells/tests/make.py +15 -0
  92. llm_tool_cli/protocol/output_cells/tests/test_automation.py +58 -0
  93. llm_tool_cli/protocol/output_cells/tests/test_base.py +86 -0
  94. llm_tool_cli/protocol/output_cells/tests/test_human.py +112 -0
  95. llm_tool_cli/protocol/output_cells/tests/test_llm.py +65 -0
  96. llm_tool_cli/protocol/serialization.py +7 -0
  97. llm_tool_cli/protocol/tests/__init__.py +0 -0
  98. llm_tool_cli/protocol/tests/helpers.py +25 -0
  99. llm_tool_cli/protocol/tests/test_cell_shortcuts.py +126 -0
  100. llm_tool_cli/protocol/tests/test_journal.py +44 -0
  101. llm_tool_cli/protocol/tests/test_serialization.py +33 -0
  102. llm_tool_cli/py.typed +0 -0
  103. llm_tool_cli/skills/__init__.py +6 -0
  104. llm_tool_cli/skills/errors.py +12 -0
  105. llm_tool_cli/skills/fixtures.py +14 -0
  106. llm_tool_cli/skills/tests/__init__.py +0 -0
  107. llm_tool_cli/skills/tests/test_fixtures.py +58 -0
  108. llm_tool_cli-0.2.0.dist-info/METADATA +275 -0
  109. llm_tool_cli-0.2.0.dist-info/RECORD +111 -0
  110. llm_tool_cli-0.2.0.dist-info/WHEEL +4 -0
  111. llm_tool_cli-0.2.0.dist-info/licenses/LICENSE +28 -0
@@ -0,0 +1,42 @@
1
+ """Public project-path failures."""
2
+
3
+ from llm_tool_cli.core import errors as core_errors
4
+
5
+
6
+ class EnvironmentError(core_errors.EnvironmentError):
7
+ """Base for expected project-path failures."""
8
+
9
+
10
+ class InvalidProjectPath(EnvironmentError):
11
+ """An input cannot identify a path below the project root."""
12
+
13
+ code: str = "invalid_project_path"
14
+ message: str = "invalid project path `{error.path}`"
15
+ path: str
16
+
17
+
18
+ class PathResolutionFailed(EnvironmentError):
19
+ """A filesystem path could not be resolved."""
20
+
21
+ code: str = "path_resolution_failed"
22
+ message: str = "could not resolve project path `{error.path}`: {error.reason}"
23
+ path: str
24
+ reason: str
25
+
26
+
27
+ class InvalidGlobPattern(EnvironmentError):
28
+ """A glob contains invalid capture syntax."""
29
+
30
+ code: str = "invalid_glob_pattern"
31
+ message: str = "invalid glob pattern `{error.pattern}`: {error.reason}"
32
+ pattern: str
33
+ reason: str
34
+
35
+
36
+ class FilesUnreadable(EnvironmentError):
37
+ """Project files could not be enumerated."""
38
+
39
+ code: str = "files_unreadable"
40
+ message: str = "could not discover files under `{error.path}`: {error.reason}"
41
+ path: str
42
+ reason: str
@@ -0,0 +1,164 @@
1
+ """Filesystem resolution and containment of project paths."""
2
+
3
+ import stat
4
+ from pathlib import Path
5
+ from typing import NewType
6
+
7
+ from llm_tool_cli.core.result import Err, Ok, Result, unwrap_to_error
8
+ from llm_tool_cli.paths.errors import InvalidProjectPath, PathResolutionFailed
9
+ from llm_tool_cli.paths.normalization import ProjectPathId, normalize_project_path_id, project_path_parts
10
+
11
+ PathInput = NewType("PathInput", Path)
12
+ ProjectConfigPath = NewType("ProjectConfigPath", Path)
13
+ ProjectRootPath = NewType("ProjectRootPath", Path)
14
+ RelativeProjectPath = NewType("RelativeProjectPath", Path)
15
+ ResolvedProjectPath = NewType("ResolvedProjectPath", Path)
16
+
17
+
18
+ @unwrap_to_error
19
+ def _project_path_input(value: str, root: ProjectRootPath, *, allow_absolute: bool = True) -> Result[PathInput]:
20
+ """Select a filesystem candidate without interpreting its target."""
21
+ if value.startswith("@"):
22
+ normalized = normalize_project_path_id(value).unwrap()
23
+ return Ok(PathInput(root.joinpath(*project_path_parts(normalized))))
24
+
25
+ try:
26
+ path = Path(value).expanduser()
27
+ except (OSError, RuntimeError) as error:
28
+ return Err([PathResolutionFailed(path=value, reason=str(error)).with_cause(error)])
29
+
30
+ if path.is_absolute() and not allow_absolute:
31
+ return Err([InvalidProjectPath(path=value)])
32
+
33
+ return Ok(PathInput(path if path.is_absolute() else root / path))
34
+
35
+
36
+ @unwrap_to_error
37
+ def resolve_project_path(value: str, root: Path, *, allow_absolute: bool = True) -> Result[ResolvedProjectPath]:
38
+ """Resolve an identifier or filesystem input strictly below a project root.
39
+
40
+ Resolve the root first. Normalize identifiers before checking for symlinks;
41
+ expand home markers only in filesystem inputs. Relative filesystem inputs
42
+ use the resolved root. When absolute inputs are disabled, reject paths
43
+ that are absolute after home expansion. Do not require an existing target.
44
+ """
45
+ project_root = resolve_project_root(root).unwrap()
46
+ candidate = _project_path_input(value, project_root, allow_absolute=allow_absolute).unwrap()
47
+ return resolve_inside_project(candidate, project_root)
48
+
49
+
50
+ @unwrap_to_error
51
+ def normalize_path(value: str, root: Path, *, cwd: Path | None = None) -> Result[ProjectPathId]:
52
+ """Normalize an identifier or filesystem input to a project identifier.
53
+
54
+ Resolve the root first and reject empty inputs. Normalize inputs starting
55
+ with ``@`` lexically; otherwise expand home markers. Enforce containment
56
+ and reject symlinks below the root in both cases. Relative inputs use the
57
+ supplied directory base, or the resolved root.
58
+ Neither form requires an existing target.
59
+ """
60
+ project_root = resolve_project_root(root).unwrap()
61
+
62
+ if not value:
63
+ return Err([InvalidProjectPath(path=value)])
64
+
65
+ if value.startswith("@"):
66
+ resolved = resolve_root_anchored_path(value, project_root).unwrap()
67
+ return Ok(project_path_id_from_resolved(resolved, project_root))
68
+
69
+ try:
70
+ path = Path(value).expanduser()
71
+ except (OSError, RuntimeError) as error:
72
+ return Err([PathResolutionFailed(path=value, reason=str(error)).with_cause(error)])
73
+
74
+ candidate = path if path.is_absolute() else (cwd if cwd is not None else project_root) / path
75
+ resolved = resolve_inside_project(candidate, project_root).unwrap()
76
+ return Ok(project_path_id_from_resolved(resolved, project_root))
77
+
78
+
79
+ @unwrap_to_error
80
+ def project_path_id_from_filesystem(path: Path, root: Path) -> Result[ProjectPathId]:
81
+ """Resolve a filesystem path and convert it to a project identifier.
82
+
83
+ Resolve the supplied root first, then enforce containment strictly below it.
84
+ Relative roots and paths use the process working directory. Do not expand
85
+ home markers, interpret identifiers, or require an existing target.
86
+ Preserve root and target resolution failures and containment diagnostics.
87
+ """
88
+ project_root = resolve_project_root(root).unwrap()
89
+ resolved = resolve_inside_project(path, project_root).unwrap()
90
+ return Ok(project_path_id_from_resolved(resolved, project_root))
91
+
92
+
93
+ def project_path_id_from_resolved(path: ResolvedProjectPath, root: ProjectRootPath) -> ProjectPathId:
94
+ """Convert a resolved path strictly below its resolved root to an identifier.
95
+
96
+ The path must already have passed containment resolution for this root.
97
+ Preserve segment text without filesystem access or repeated validation.
98
+ """
99
+ return ProjectPathId("@/" + path.relative_to(root).as_posix())
100
+
101
+
102
+ def resolve_project_root(root: Path) -> Result[ProjectRootPath]:
103
+ """Resolve a filesystem root without requiring an existing directory.
104
+
105
+ Relative roots use the process working directory. Resolve symlinks and
106
+ parent segments without expanding home markers. Return filesystem
107
+ resolution failures with their original cause.
108
+ """
109
+ try:
110
+ return Ok(ProjectRootPath(root.resolve()))
111
+ except (OSError, RuntimeError) as error:
112
+ return Err([PathResolutionFailed(path=str(root), reason=str(error)).with_cause(error)])
113
+
114
+
115
+ def _validate_no_symlinks(path: Path, root: ProjectRootPath) -> Result[None]: # noqa: CCR001
116
+ """Reject links below the resolved root before resolving the target."""
117
+ candidate = path.absolute()
118
+ current = Path(candidate.anchor)
119
+ for part in candidate.parts[1:]:
120
+ current = current.parent if part == ".." else current / part
121
+ if current == root or not current.is_relative_to(root):
122
+ # Root aliases and symlinks above the chosen root remain supported.
123
+ current = current.resolve()
124
+ continue
125
+ try:
126
+ mode = current.lstat().st_mode
127
+ except (FileNotFoundError, NotADirectoryError):
128
+ continue
129
+ if stat.S_ISLNK(mode):
130
+ return Err([InvalidProjectPath(path=str(path))])
131
+ return Ok(None)
132
+
133
+
134
+ @unwrap_to_error
135
+ def resolve_inside_project(path: Path, root: ProjectRootPath) -> Result[ResolvedProjectPath]:
136
+ """Resolve a filesystem path and require it to be strictly below the root.
137
+
138
+ The root must already be resolved. Relative paths use the process working
139
+ directory; callers choose any other base before calling. Do not expand
140
+ home markers or require an existing target.
141
+ """
142
+ try:
143
+ _validate_no_symlinks(path, root).unwrap()
144
+ resolved = path.resolve()
145
+ except (OSError, RuntimeError) as error:
146
+ return Err([PathResolutionFailed(path=str(path), reason=str(error)).with_cause(error)])
147
+
148
+ if resolved == root or not resolved.is_relative_to(root):
149
+ return Err([InvalidProjectPath(path=str(path))])
150
+
151
+ return Ok(ResolvedProjectPath(resolved))
152
+
153
+
154
+ @unwrap_to_error
155
+ def resolve_root_anchored_path(value: str, root: ProjectRootPath) -> Result[ResolvedProjectPath]:
156
+ """Normalize an ``@/`` identifier and resolve it strictly below the root.
157
+
158
+ The root must already be resolved. Normalize identifier segments before
159
+ resolving symlinks and checking containment, without requiring an existing
160
+ target. Preserve lexical and filesystem resolution diagnostics.
161
+ """
162
+ normalized = normalize_project_path_id(value).unwrap()
163
+ path = root.joinpath(*project_path_parts(normalized))
164
+ return resolve_inside_project(path, root)
@@ -0,0 +1,124 @@
1
+ """Shared project glob preparation, matching, and named captures."""
2
+
3
+ import fnmatch
4
+ import glob
5
+ import os.path
6
+ import re
7
+ from pathlib import Path
8
+ from typing import NewType
9
+
10
+ from llm_tool_cli.core.result import Err, Ok, Result, unwrap_to_error
11
+ from llm_tool_cli.paths.errors import InvalidGlobPattern, InvalidProjectPath, PathResolutionFailed
12
+ from llm_tool_cli.paths.filesystem import (
13
+ ProjectRootPath,
14
+ _project_path_input,
15
+ _validate_no_symlinks,
16
+ resolve_project_root,
17
+ )
18
+ from llm_tool_cli.paths.normalization import ProjectPathId, project_path_parts
19
+
20
+ FileGlobPattern = NewType("FileGlobPattern", str)
21
+ """A supplied filesystem glob without validity or containment guarantees."""
22
+
23
+
24
+ @unwrap_to_error
25
+ def _pattern_parts(pattern: FileGlobPattern, root: ProjectRootPath) -> Result[tuple[str, ...]]:
26
+ """Resolve the literal prefix without treating glob syntax as filenames."""
27
+ candidate = _project_path_input(pattern, root).unwrap()
28
+ parts = candidate.parts
29
+ start = len(root.parts) if candidate.is_relative_to(root) else 0
30
+ first_magic = next((index for index in range(start, len(parts)) if glob.has_magic(parts[index])), len(parts))
31
+ prefix = Path(*parts[:first_magic])
32
+ try:
33
+ _validate_no_symlinks(prefix, root).unwrap()
34
+ resolved = prefix.resolve().joinpath(*parts[first_magic:])
35
+ except (OSError, RuntimeError) as error:
36
+ return Err([PathResolutionFailed(path=str(candidate), reason=str(error)).with_cause(error)])
37
+ normalized = Path(os.path.normpath(resolved))
38
+ if normalized == root or not normalized.is_relative_to(root):
39
+ return Err([InvalidProjectPath(path=str(candidate))])
40
+ return Ok(normalized.relative_to(root).parts)
41
+
42
+
43
+ def _compile_glob(pattern: FileGlobPattern) -> Result[tuple[re.Pattern[str], bool]]: # noqa: CCR001, TAE002
44
+ """Compile path syntax and report whether it can cross directory boundaries."""
45
+ regex: list[str] = []
46
+ recursive = False
47
+ index = 0
48
+ while index < len(pattern):
49
+ character = pattern[index]
50
+ if character == "[":
51
+ end = index + 1
52
+ if end < len(pattern) and pattern[end] == "!":
53
+ end += 1
54
+ if end < len(pattern) and pattern[end] == "]":
55
+ end += 1
56
+ end = pattern.find("]", end)
57
+ if end != -1 and "/" not in pattern[index : end + 1]:
58
+ # Delegate character-class rules (including escaping and invalid
59
+ # ranges) to fnmatch, removing its whole-string wrapper.
60
+ translated = fnmatch.translate(pattern[index : end + 1])
61
+ regex.append("(?!/)" + translated[4:-3])
62
+ index = end + 1
63
+ continue
64
+
65
+ if character == "{" and pattern[index + 1 : index + 2] == "*":
66
+ end = pattern.find("}", index)
67
+ if end == -1:
68
+ return Err([InvalidGlobPattern(pattern=pattern, reason="unclosed capture")])
69
+ token = pattern[index + 1 : end]
70
+ wildcard = "**" if token.startswith("**") else "*"
71
+ name = token[len(wildcard) :]
72
+ if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name) is None:
73
+ return Err([InvalidGlobPattern(pattern=pattern, reason=f"invalid capture name `{name}`")])
74
+ index = end + 1
75
+ if wildcard == "**":
76
+ recursive = True
77
+ if pattern[index : index + 1] == "/":
78
+ regex.append(f"(?:(?P<{name}>.*)/)?")
79
+ index += 1
80
+ else:
81
+ regex.append(f"(?P<{name}>.*)")
82
+ else:
83
+ regex.append(f"(?P<{name}>[^/]*)")
84
+ continue
85
+
86
+ if pattern[index : index + 2] == "**":
87
+ recursive = True
88
+ index += 2
89
+ if pattern[index : index + 1] == "/":
90
+ regex.append("(?:.*/)?")
91
+ index += 1
92
+ else:
93
+ regex.append(".*")
94
+ continue
95
+
96
+ regex.append("[^/]*" if character == "*" else "[^/]" if character == "?" else re.escape(character))
97
+ index += 1
98
+
99
+ try:
100
+ return Ok((re.compile("".join(regex), re.DOTALL), recursive))
101
+ except re.error as error:
102
+ return Err([InvalidGlobPattern(pattern=pattern, reason=str(error)).with_cause(error)])
103
+
104
+
105
+ @unwrap_to_error
106
+ def glob_capture_names(pattern: FileGlobPattern) -> Result[set[str]]:
107
+ """Validate glob syntax and collect capture names without filesystem access."""
108
+ compiled, _ = _compile_glob(pattern).unwrap()
109
+ return Ok(set(compiled.groupindex))
110
+
111
+
112
+ @unwrap_to_error
113
+ def match_glob(path: ProjectPathId, pattern: FileGlobPattern, root: Path) -> Result[dict[str, str] | None]:
114
+ """Match an already normalized identifier, including a missing reference.
115
+
116
+ Only the pattern's literal prefix is resolved against the filesystem.
117
+ Return captured strings (empty for omitted recursive directories), an empty
118
+ dictionary for a match without captures, or None for a non-match.
119
+ """
120
+ project_root = resolve_project_root(root).unwrap()
121
+ parts = _pattern_parts(pattern, project_root).unwrap()
122
+ compiled, _ = _compile_glob(FileGlobPattern("/".join(parts))).unwrap()
123
+ match = compiled.fullmatch("/".join(project_path_parts(path)))
124
+ return Ok({name: value or "" for name, value in match.groupdict().items()} if match else None)
@@ -0,0 +1,58 @@
1
+ """Lexical operations on project-path identifiers."""
2
+
3
+ from typing import NewType
4
+
5
+ from llm_tool_cli.core.result import Err, Ok, Result
6
+ from llm_tool_cli.paths.errors import InvalidProjectPath
7
+
8
+ ProjectPathId = NewType("ProjectPathId", str)
9
+
10
+ _PROJECT_ROOT_PREFIX = "@/"
11
+
12
+
13
+ def normalize_project_path_id(value: str) -> Result[ProjectPathId]:
14
+ """Normalize a root-anchored identifier without accessing the filesystem.
15
+
16
+ Remove ``.`` and resolve ``..`` lexically. Reject missing ``@/``, empty
17
+ segments, traversal above the root, and a final root-only identifier with
18
+ ``Err([InvalidProjectPath(...)])``. Preserve all other segment text.
19
+ """
20
+ if not value.startswith(_PROJECT_ROOT_PREFIX):
21
+ return Err([InvalidProjectPath(path=value)])
22
+
23
+ parts: list[str] = []
24
+ for part in value.removeprefix(_PROJECT_ROOT_PREFIX).split("/"):
25
+ match part:
26
+ case "":
27
+ return Err([InvalidProjectPath(path=value)])
28
+ case ".":
29
+ continue
30
+ case "..":
31
+ if not parts:
32
+ return Err([InvalidProjectPath(path=value)])
33
+ parts.pop()
34
+ case _:
35
+ parts.append(part)
36
+
37
+ if not parts:
38
+ return Err([InvalidProjectPath(path=value)])
39
+
40
+ return Ok(ProjectPathId(_PROJECT_ROOT_PREFIX + "/".join(parts)))
41
+
42
+
43
+ def is_project_path_id(value: object) -> bool:
44
+ """Check whether an input is already a canonical project-path identifier.
45
+
46
+ Return false for non-strings and values requiring lexical normalization.
47
+ Do not access the filesystem or apply application-specific rules.
48
+ """
49
+ return isinstance(value, str) and normalize_project_path_id(value).ok() == value
50
+
51
+
52
+ def project_path_parts(value: ProjectPathId) -> tuple[str, ...]:
53
+ """Return the components of an already canonical project-path identifier.
54
+
55
+ The caller must establish canonical form before calling. Preserve segment
56
+ text without filesystem access or repeated normalization and validation.
57
+ """
58
+ return tuple(value.removeprefix(_PROJECT_ROOT_PREFIX).split("/"))
File without changes
@@ -0,0 +1,229 @@
1
+ import glob
2
+ from collections.abc import Iterator
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+ from pytest_mock import MockerFixture
7
+
8
+ from llm_tool_cli.paths import FileGlobPattern, ProjectPathId, enumerate_files, match_glob
9
+ from llm_tool_cli.paths.enumeration import _glob_files, _walk_files
10
+ from llm_tool_cli.paths.errors import FilesUnreadable, InvalidGlobPattern, InvalidProjectPath, PathResolutionFailed
11
+
12
+
13
+ class TestWalkFiles:
14
+ def test_only_regular_files_without_following_links(self, tmp_path: Path) -> None:
15
+ (tmp_path / "nested").mkdir()
16
+ (tmp_path / "nested/file").touch()
17
+ (tmp_path / "cycle").symlink_to(tmp_path)
18
+ (tmp_path / "link").symlink_to(tmp_path / "nested/file")
19
+ (tmp_path / "broken").symlink_to(tmp_path / "missing")
20
+
21
+ assert list(_walk_files(tmp_path)) == [tmp_path / "nested/file"]
22
+ assert list(_walk_files(tmp_path / "missing")) == []
23
+ assert list(_walk_files(tmp_path / "nested/file/child")) == []
24
+
25
+
26
+ class TestGlobFiles:
27
+ def test_recursive_patterns_dedicated_components(self, tmp_path: Path) -> None:
28
+ (tmp_path / "nested").mkdir()
29
+ (tmp_path / "nested/a.py").touch()
30
+ (tmp_path / "b.py").touch()
31
+ (tmp_path / "broken.py").symlink_to(tmp_path / "absent")
32
+
33
+ assert sorted(_glob_files(tmp_path, ("**", "*.py"))) == [tmp_path / "b.py", tmp_path / "nested/a.py"]
34
+
35
+ def test_missing_or_non_directory_prefix_has_no_matches(self, tmp_path: Path) -> None:
36
+ (tmp_path / "file").touch()
37
+
38
+ assert list(_glob_files(tmp_path / "missing", ("*",))) == []
39
+ assert list(_glob_files(tmp_path / "file/child", ("*",))) == []
40
+ assert list(_glob_files(tmp_path / "file", ("*",))) == []
41
+
42
+
43
+ class TestEnumerateFiles:
44
+ @pytest.mark.parametrize(
45
+ "pattern",
46
+ [
47
+ "**/*",
48
+ "**",
49
+ "**/**",
50
+ "src/[ab].py",
51
+ "src/[!b]*.py",
52
+ "src/a**.py",
53
+ "src/{*module}.py",
54
+ "src/{**module}.py",
55
+ "{**directory}/{*file}",
56
+ "src/star[*].py",
57
+ "src/**/a?.py",
58
+ "src/[[]literal].py",
59
+ "src/a.py/**",
60
+ "src/[**].py",
61
+ "src/[a/nested].py",
62
+ "src/Case.py",
63
+ "src/[!x]*.py",
64
+ ],
65
+ )
66
+ def test_enumeration_and_matching_share_the_same_grammar(self, tmp_path: Path, pattern: str) -> None:
67
+ names = [
68
+ "src/a.py",
69
+ "src/b.py",
70
+ "src/a/nested.py",
71
+ "src/.hidden/a1.py",
72
+ "src/.hidden.py",
73
+ "src/star*.py",
74
+ "src/[literal].py",
75
+ "src/line\nbreak.py",
76
+ "src/*.py",
77
+ "src/case.py",
78
+ "docs/a.py",
79
+ "root.txt",
80
+ ]
81
+ for name in names:
82
+ path = tmp_path / name
83
+ path.parent.mkdir(parents=True, exist_ok=True)
84
+ path.touch()
85
+ pattern_value = FileGlobPattern(pattern)
86
+ expected = [
87
+ "@/" + name
88
+ for name in sorted(names, key=lambda name: Path(name).parts)
89
+ if match_glob(ProjectPathId("@/" + name), pattern_value, tmp_path).unwrap() is not None
90
+ ]
91
+
92
+ assert enumerate_files(tmp_path, pattern_value).unwrap() == expected
93
+
94
+ def test_recursive_captures_and_embedded_wildcards_select_nested_files(self, tmp_path: Path) -> None:
95
+ (tmp_path / "a").mkdir()
96
+ (tmp_path / "a/nested.py").touch()
97
+ (tmp_path / "a.py").touch()
98
+
99
+ for pattern in ["a**.py", "{**module}.py"]:
100
+ assert enumerate_files(tmp_path, FileGlobPattern(pattern)).unwrap() == ["@/a/nested.py", "@/a.py"]
101
+
102
+ @pytest.mark.parametrize("pattern", ["{*bad-name}", "{*name}/{*name}", "{*unclosed"])
103
+ def test_invalid_captures_fail_even_in_an_empty_directory(self, tmp_path: Path, pattern: str) -> None:
104
+ assert enumerate_files(tmp_path, FileGlobPattern(pattern)).is_err(InvalidGlobPattern)
105
+
106
+ def test_nonmatching_directories_are_not_traversed(self, tmp_path: Path, mocker: MockerFixture) -> None:
107
+ (tmp_path / "src").mkdir()
108
+ (tmp_path / "src/file.py").touch()
109
+ (tmp_path / "unrelated").mkdir()
110
+ iterdir = Path.iterdir
111
+
112
+ def read_directory(path: Path) -> Iterator[Path]:
113
+ if path == tmp_path / "unrelated":
114
+ raise PermissionError("must not traverse unrelated directory")
115
+ return iterdir(path)
116
+
117
+ mocker.patch.object(Path, "iterdir", autospec=True, side_effect=read_directory)
118
+
119
+ assert enumerate_files(tmp_path, FileGlobPattern("s?c/**/*.py")).unwrap() == ["@/src/file.py"]
120
+
121
+ def test_wildcards_do_not_resolve_literal_symlink_names(self, tmp_path: Path) -> None:
122
+ (tmp_path / "ordinary").touch()
123
+ (tmp_path / "*").symlink_to(tmp_path)
124
+ (tmp_path / "**").symlink_to(tmp_path)
125
+
126
+ assert enumerate_files(tmp_path).unwrap() == ["@/ordinary"]
127
+
128
+ @pytest.mark.parametrize("pattern", ["**/*", "@/**/*", "**", "**/**"])
129
+ def test_unique_files_in_component_order_including_hidden_entries(self, tmp_path: Path, pattern: str) -> None:
130
+ names = ["a/child", "a.txt", ".hidden/file", ".hidden-file", "z"]
131
+ for name in names:
132
+ path = tmp_path / name
133
+ path.parent.mkdir(exist_ok=True)
134
+ path.touch()
135
+ (tmp_path / "empty").mkdir()
136
+
137
+ assert enumerate_files(tmp_path, FileGlobPattern(pattern)).unwrap() == [
138
+ "@/.hidden/file",
139
+ "@/.hidden-file",
140
+ "@/a/child",
141
+ "@/a.txt",
142
+ "@/z",
143
+ ]
144
+
145
+ @pytest.mark.parametrize("pattern", ["**/*.py", "@/**/*.py", "**/a?.[pt][xy]", "**/[!b]*.py"])
146
+ def test_glob_syntax_and_zero_directory_recursion(self, tmp_path: Path, pattern: str) -> None:
147
+ (tmp_path / "nested").mkdir()
148
+ for name in ["a1.py", "nested/a2.py", "b.txt"]:
149
+ (tmp_path / name).touch()
150
+
151
+ assert enumerate_files(tmp_path, FileGlobPattern(pattern)).unwrap() == ["@/a1.py", "@/nested/a2.py"]
152
+
153
+ @pytest.mark.parametrize("pattern", ["**/*", "**", "*.py", "*/**/*"])
154
+ def test_skips_all_discovered_links(self, tmp_path: Path, pattern: str) -> None:
155
+ project = tmp_path / "project"
156
+ project.mkdir()
157
+ target = tmp_path / "outside"
158
+ target.mkdir()
159
+ (target / "outside.py").touch()
160
+ (project / "ordinary.py").touch()
161
+ for name, destination in [
162
+ ("file.py", project / "ordinary.py"),
163
+ ("external.py", target / "outside.py"),
164
+ ("directory", target),
165
+ ("cycle", project),
166
+ ("broken.py", project / "missing"),
167
+ ]:
168
+ (project / name).symlink_to(destination)
169
+
170
+ expected = [] if pattern == "*/**/*" else ["@/ordinary.py"]
171
+ assert enumerate_files(project, FileGlobPattern(pattern)).unwrap() == expected
172
+
173
+ @pytest.mark.parametrize("suffix", ["", "/**/*", "/../ordinary"])
174
+ def test_rejects_explicit_symlink_prefix(self, tmp_path: Path, suffix: str) -> None:
175
+ (tmp_path / "link").symlink_to(tmp_path)
176
+ result = enumerate_files(tmp_path, FileGlobPattern("link" + suffix))
177
+
178
+ assert result.is_err(InvalidProjectPath)
179
+
180
+ def test_missing_directory_or_file_root_produces_no_files(self, tmp_path: Path) -> None:
181
+ (tmp_path / "file").touch()
182
+
183
+ assert enumerate_files(tmp_path, FileGlobPattern("missing/**/*")).unwrap() == []
184
+ assert enumerate_files(tmp_path / "missing").unwrap() == []
185
+ assert enumerate_files(tmp_path / "file").unwrap() == []
186
+
187
+ def test_literal_pattern_and_escaped_metacharacters(self, tmp_path: Path) -> None:
188
+ directory = tmp_path / "[work]*?"
189
+ directory.mkdir()
190
+ (directory / "file.py").touch()
191
+
192
+ assert enumerate_files(tmp_path, FileGlobPattern(glob.escape(directory.name) + "/file.py")).unwrap() == [
193
+ "@/[work]*?/file.py"
194
+ ]
195
+
196
+ def test_absolute_and_home_patterns(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
197
+ monkeypatch.setenv("HOME", str(tmp_path))
198
+ (tmp_path / "file.py").touch()
199
+
200
+ assert enumerate_files(tmp_path, FileGlobPattern(str(tmp_path / "*.py"))).unwrap() == ["@/file.py"]
201
+ assert enumerate_files(tmp_path, FileGlobPattern("~/*.py")).unwrap() == ["@/file.py"]
202
+
203
+ @pytest.mark.parametrize("pattern", ["../*", "@/../*", "@/", "@/a//b", "."])
204
+ def test_invalid_patterns_preserve_diagnostics(self, tmp_path: Path, pattern: str) -> None:
205
+ assert enumerate_files(tmp_path, FileGlobPattern(pattern)).is_err(InvalidProjectPath)
206
+
207
+ def test_root_resolution_failure_precedes_pattern_processing(self, tmp_path: Path) -> None:
208
+ root = tmp_path / "loop"
209
+ root.symlink_to(root)
210
+
211
+ assert enumerate_files(root, FileGlobPattern("@/../*")).is_err(PathResolutionFailed)
212
+
213
+ def test_directory_read_failure_is_not_an_empty_result(self, tmp_path: Path, mocker: MockerFixture) -> None:
214
+ cause = PermissionError("cannot list directory")
215
+ mocker.patch.object(Path, "iterdir", side_effect=cause)
216
+
217
+ failure = enumerate_files(tmp_path).unwrap_err()[0]
218
+
219
+ assert isinstance(failure, FilesUnreadable)
220
+ assert failure.code == "files_unreadable"
221
+ assert failure.path == str(tmp_path)
222
+ assert failure.cause == cause
223
+ assert "cause" not in failure.as_record()
224
+
225
+ def test_unexpected_failure_remains_an_exception(self, tmp_path: Path, mocker: MockerFixture) -> None:
226
+ mocker.patch.object(Path, "iterdir", side_effect=ValueError("unexpected failure"))
227
+
228
+ with pytest.raises(ValueError, match="unexpected failure"):
229
+ enumerate_files(tmp_path)