ace-support-fs 0.3.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2c4e0121b057bd9f4bc10f6ecfd197a0df1a6050e18f1a82a855e4364a85629c
4
+ data.tar.gz: 23b62d54c4026a400e90c2a72577b87c8e482219d9035f1a867713d76a511e40
5
+ SHA512:
6
+ metadata.gz: 329d52c1e0c1fb79a4466732188680570b3003cd3333a29a87c1283bc235d0d4f38c53d34e6b118aa9fe53e78e0197527f867714e5e58ccaa3a9bec0d93a8321
7
+ data.tar.gz: 3abd750584076f9e59e94a39651db0cafbf58efac334282f083c9fffcb4e541f37d877155512e5ceed0b88f92a929271cbe8c95d772fa2fcbbef5686960a2da0
data/CHANGELOG.md ADDED
@@ -0,0 +1,74 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.3.0] - 2026-03-23
11
+
12
+ ### Technical
13
+ - Removed phantom `handbook/**/*` glob from gemspec (no handbook directory exists).
14
+
15
+ ## [0.2.3] - 2026-03-22
16
+
17
+ ### Technical
18
+ - Updated README dependency example to use the current `~> 0.2` version constraint.
19
+
20
+ ## [0.2.2] - 2026-03-22
21
+
22
+ ### Technical
23
+ - Refreshed README structure with consistent tagline, overview, basic usage, and ACE project footer
24
+
25
+ ## [0.2.1] - 2026-03-08
26
+
27
+ ### Fixed
28
+ - Ignore `PROJECT_ROOT_PATH` overrides when the configured root does not contain the active `start_path`, preventing unrelated workspace roots from leaking into traversal boundaries.
29
+
30
+ ### Technical
31
+ - Added regression coverage for environment-root fallback behavior in `ProjectRootFinder`.
32
+ - Stabilized directory traversal molecule tests by isolating no-project-root fixtures from ambient system-level config directories.
33
+
34
+ ## [0.2.0] - 2026-01-03
35
+
36
+ ### Changed
37
+ - **BREAKING**: Minimum Ruby version raised to 3.3.0 (was 3.1.0)
38
+ - Standardized gemspec file patterns with deterministic Dir.glob
39
+ - Added MIT LICENSE file
40
+
41
+ ## [0.1.0] - 2025-12-28
42
+
43
+ ### Added
44
+
45
+ - Initial release extracting filesystem utilities from ace-support-core and ace-config
46
+ - **PathExpander** atom with:
47
+ - Instance-based API (`for_file`, `for_cli`) for context-aware path resolution
48
+ - Protocol URI support (wfi://, guide://, tmpl://, etc.) with pluggable resolver
49
+ - Environment variable expansion ($VAR and ${VAR} formats)
50
+ - Source-relative (./) and project-relative path resolution
51
+ - Thread-safe protocol resolver registration with Mutex
52
+ - Backward-compatible class methods (expand, join, dirname, basename, etc.)
53
+ - Testability helper (`class_get_env` for ENV stubbing in tests)
54
+ - **ProjectRootFinder** molecule with:
55
+ - Project root detection by marker files (.git, Gemfile, package.json, etc.)
56
+ - Thread-safe caching with Mutex
57
+ - PROJECT_ROOT_PATH environment variable support
58
+ - Instance and class method APIs
59
+ - Testability helper (`env_project_root` for ENV stubbing)
60
+ - **DirectoryTraverser** molecule with:
61
+ - Config directory discovery from current to project root
62
+ - Cascade priority building for nearest-wins resolution
63
+ - Home directory inclusion with lower priority
64
+ - Configurable config directory name (default: `.ace`)
65
+ - **PathError** exception class for protocol resolution failures
66
+
67
+ ### Design Decisions
68
+
69
+ - Exception-based error handling (raise PathError) following ace-config pattern
70
+ - Thread-safety with instance variables + Mutex (not class variables)
71
+ - DEFAULT_MARKERS constant for project root markers
72
+ - `config_dir` parameter name (not `config_dir_name`)
73
+ - `class_get_env` for testability without subprocess overhead
74
+ - No Windows support (not tested or supported)
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Michal Czyz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,29 @@
1
+ <div align="center">
2
+ <h1> ACE - Support FS </h1>
3
+
4
+ File system primitives for ACE path resolution and project root discovery.
5
+
6
+ <img src="https://raw.githubusercontent.com/cs3b/ace/main/docs/brand/AgenticCodingEnvironment.Logo.XS.jpg" alt="ACE Logo" width="480">
7
+ <br><br>
8
+
9
+ <a href="https://rubygems.org/gems/ace-support-fs"><img alt="Gem Version" src="https://img.shields.io/gem/v/ace-support-fs.svg" /></a>
10
+ <a href="https://www.ruby-lang.org"><img alt="Ruby" src="https://img.shields.io/badge/Ruby-3.2+-CC342D?logo=ruby" /></a>
11
+ <a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-blue.svg" /></a>
12
+
13
+ </div>
14
+
15
+ > Works with: Claude Code, Codex CLI, OpenCode, Gemini CLI, pi-agent, and more.
16
+
17
+ `ace-support-fs` provides reusable filesystem helpers for path expansion, root detection, and directory traversal. It handles the platform and context differences so packages like [ace-support-config](../ace-support-config) and [ace-search](../ace-search) can resolve paths safely from any working directory.
18
+
19
+ ## Use Cases
20
+
21
+ **Resolve paths safely across subdirectories** - use consistent path expansion for tools that move across project subdirectories, including protocol-aware path handling.
22
+
23
+ **Detect workspace boundaries** - infer project root from marker files without shell-specific assumptions, so [ace-search](../ace-search) and [ace-support-config](../ace-support-config) scope correctly.
24
+
25
+ **Build config scans** - discover and rank candidate configuration directories during resolution, supporting the cascade logic in [ace-support-config](../ace-support-config).
26
+
27
+ ---
28
+
29
+ Part of [ACE](https://github.com/cs3b/ace)
data/Rakefile ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test" << "lib"
8
+ t.test_files = FileList["test/**/*_test.rb"]
9
+ end
10
+
11
+ # Add :spec alias for CI compatibility
12
+ task spec: :test
13
+
14
+ task default: :test
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Ace
6
+ module Support
7
+ module Fs
8
+ module Atoms
9
+ # Path expansion and resolution with automatic context inference
10
+ #
11
+ # Supports:
12
+ # - Instance-based API for context-aware resolution
13
+ # - Protocol URIs (wfi://, guide://, tmpl://, task://, prompt://)
14
+ # - Source-relative paths (./, ../)
15
+ # - Project-relative paths (no prefix)
16
+ # - Environment variables ($VAR, ${VAR})
17
+ # - Backward compatible class methods for utilities
18
+ class PathExpander
19
+ # Protocol pattern for URI detection
20
+ PROTOCOL_PATTERN = %r{^[a-z][a-z0-9+.-]*://}
21
+
22
+ # Instance attributes
23
+ attr_reader :source_dir, :project_root
24
+
25
+ # Thread-safe protocol resolver registry
26
+ @protocol_resolver = nil
27
+ @protocol_resolver_mutex = Mutex.new
28
+
29
+ class << self
30
+ private
31
+
32
+ attr_reader :protocol_resolver_mutex
33
+ end
34
+
35
+ # === Factory Methods ===
36
+
37
+ # Create expander for a source file (config, workflow, template, prompt)
38
+ # Automatically infers source_dir, uses provided or default project_root
39
+ #
40
+ # @param source_file [String] Path to source file
41
+ # @param project_root [String, nil] Optional explicit project root (recommended)
42
+ # @return [PathExpander] Instance with inferred context
43
+ def self.for_file(source_file, project_root: nil)
44
+ expanded_source = File.expand_path(source_file)
45
+ source_dir = File.dirname(expanded_source)
46
+
47
+ # Use provided project_root or fall back to current directory
48
+ # Note: For full project root detection, caller should use
49
+ # Ace::Support::Fs::Molecules::ProjectRootFinder and pass the result
50
+ resolved_root = project_root || Dir.pwd
51
+
52
+ new(source_dir: source_dir, project_root: resolved_root)
53
+ end
54
+
55
+ # Create expander for CLI context (no source file)
56
+ # Uses current directory as source_dir
57
+ #
58
+ # @param project_root [String, nil] Optional explicit project root
59
+ # @return [PathExpander] Instance with CLI context
60
+ def self.for_cli(project_root: nil)
61
+ resolved_root = project_root || Dir.pwd
62
+
63
+ new(
64
+ source_dir: Dir.pwd,
65
+ project_root: resolved_root
66
+ )
67
+ end
68
+
69
+ # === Instance Methods ===
70
+
71
+ # Initialize with explicit context
72
+ #
73
+ # @param source_dir [String] Source document directory (REQUIRED)
74
+ # @param project_root [String] Project root directory (REQUIRED)
75
+ # @raise [ArgumentError] if either parameter is nil
76
+ def initialize(source_dir:, project_root:)
77
+ if source_dir.nil? || project_root.nil?
78
+ raise ArgumentError, "PathExpander requires both 'source_dir' and 'project_root' " \
79
+ "(got source_dir: #{source_dir.inspect}, project_root: #{project_root.inspect})"
80
+ end
81
+
82
+ @source_dir = source_dir
83
+ @project_root = project_root
84
+ end
85
+
86
+ # Resolve path using instance context
87
+ # Handles: protocols, source-relative (./), project-relative, env vars, absolute
88
+ #
89
+ # @param path [String] Path to resolve
90
+ # @return [String] Resolved absolute path
91
+ # @raise [PathError] When protocol cannot be resolved
92
+ def resolve(path)
93
+ return nil if path.nil? || path.empty?
94
+
95
+ path_str = path.to_s
96
+
97
+ # Check for protocol URIs first
98
+ if self.class.protocol?(path_str)
99
+ return resolve_protocol(path_str)
100
+ end
101
+
102
+ # Expand environment variables
103
+ expanded = expand_env_vars(path_str)
104
+
105
+ # Handle absolute paths
106
+ return File.expand_path(expanded) if Pathname.new(expanded).absolute?
107
+
108
+ # Handle source-relative paths (./ or ../)
109
+ if expanded.start_with?("./", "../")
110
+ return File.expand_path(expanded, @source_dir)
111
+ end
112
+
113
+ # Default: project-relative paths
114
+ File.expand_path(expanded, @project_root)
115
+ end
116
+
117
+ # === Class Methods (Utilities and Backward Compatibility) ===
118
+
119
+ # Check if path is a protocol URI
120
+ #
121
+ # @param path [String] Path to check
122
+ # @return [Boolean] true if protocol format detected
123
+ def self.protocol?(path)
124
+ return false if path.nil? || path.empty?
125
+
126
+ !!(path.to_s =~ PROTOCOL_PATTERN)
127
+ end
128
+
129
+ # Register a protocol resolver (e.g., ace-nav)
130
+ # Thread-safe registration using mutex.
131
+ #
132
+ # @param resolver [Object] Resolver responding to #resolve(uri)
133
+ def self.register_protocol_resolver(resolver)
134
+ protocol_resolver_mutex.synchronize do
135
+ @protocol_resolver = resolver
136
+ end
137
+ end
138
+
139
+ # Get the current protocol resolver (thread-safe)
140
+ # @return [Object, nil] Current resolver or nil
141
+ def self.protocol_resolver
142
+ protocol_resolver_mutex.synchronize do
143
+ @protocol_resolver
144
+ end
145
+ end
146
+
147
+ # Reset protocol resolver (for testing)
148
+ # @api private
149
+ def self.reset_protocol_resolver!
150
+ protocol_resolver_mutex.synchronize do
151
+ @protocol_resolver = nil
152
+ end
153
+ end
154
+
155
+ # Expand path with tilde and environment variables
156
+ # Legacy stateless method for backward compatibility
157
+ #
158
+ # @param path [String] Path to expand
159
+ # @return [String] Expanded absolute path
160
+ def self.expand(path)
161
+ return nil if path.nil?
162
+
163
+ expanded = path.to_s.dup
164
+
165
+ # Expand environment variables (uses class_get_env for testability)
166
+ expanded.gsub!(/\$([A-Z_][A-Z0-9_]*)/i) do |match|
167
+ class_get_env(match[1..-1]) || match
168
+ end
169
+
170
+ # Expand tilde
171
+ File.expand_path(expanded)
172
+ end
173
+
174
+ # Access environment variable by name (class-level)
175
+ # Extracted to allow test stubbing without modifying global ENV
176
+ #
177
+ # @param var_name [String] Environment variable name
178
+ # @return [String, nil] Environment variable value or nil if not set
179
+ def self.class_get_env(var_name)
180
+ ENV[var_name]
181
+ end
182
+
183
+ # Join path components safely
184
+ #
185
+ # @param parts [Array<String>] Path parts to join
186
+ # @return [String] Joined path
187
+ def self.join(*parts)
188
+ parts = parts.flatten.compact.map(&:to_s)
189
+ return "" if parts.empty?
190
+
191
+ File.join(*parts)
192
+ end
193
+
194
+ # Get directory name from path
195
+ #
196
+ # @param path [String] File path
197
+ # @return [String] Directory path
198
+ def self.dirname(path)
199
+ return nil if path.nil?
200
+
201
+ File.dirname(path.to_s)
202
+ end
203
+
204
+ # Get base name from path
205
+ #
206
+ # @param path [String] File path
207
+ # @return [String] Base name
208
+ def self.basename(path, suffix = nil)
209
+ return nil if path.nil?
210
+
211
+ if suffix
212
+ File.basename(path.to_s, suffix)
213
+ else
214
+ File.basename(path.to_s)
215
+ end
216
+ end
217
+
218
+ # Check if path is absolute
219
+ #
220
+ # @param path [String] Path to check
221
+ # @return [Boolean] true if absolute path
222
+ def self.absolute?(path)
223
+ return false if path.nil?
224
+
225
+ path_str = path.to_s
226
+ Pathname.new(path_str).absolute?
227
+ end
228
+
229
+ # Make path relative to base
230
+ #
231
+ # @param path [String] Path to make relative
232
+ # @param base [String] Base path
233
+ # @return [String] Relative path
234
+ def self.relative(path, base)
235
+ return nil if path.nil? || base.nil?
236
+
237
+ path_obj = Pathname.new(expand(path))
238
+ base_obj = Pathname.new(expand(base))
239
+
240
+ path_obj.relative_path_from(base_obj).to_s
241
+ rescue ArgumentError
242
+ # Paths are on different drives or one is relative
243
+ path
244
+ end
245
+
246
+ # Normalize path (remove .., ., duplicates slashes)
247
+ #
248
+ # @param path [String] Path to normalize
249
+ # @return [String] Normalized path
250
+ def self.normalize(path)
251
+ return nil if path.nil?
252
+
253
+ Pathname.new(path.to_s).cleanpath.to_s
254
+ end
255
+
256
+ protected
257
+
258
+ # Access environment variable by name
259
+ # Extracted to allow test stubbing without modifying global ENV
260
+ #
261
+ # @param var_name [String] Environment variable name
262
+ # @return [String, nil] Environment variable value or nil if not set
263
+ def get_env(var_name)
264
+ ENV[var_name]
265
+ end
266
+
267
+ private
268
+
269
+ # Resolve protocol URI
270
+ # @raise [PathError] if no protocol resolver is registered
271
+ def resolve_protocol(uri)
272
+ resolver = self.class.protocol_resolver
273
+ if resolver && resolver.respond_to?(:resolve)
274
+ result = resolver.resolve(uri)
275
+ # If resolver returns a Resource with path, extract it
276
+ return result.path if result.respond_to?(:path)
277
+ # Otherwise return the result as-is
278
+ return result
279
+ end
280
+
281
+ # No resolver registered - raise exception for consistent error handling
282
+ raise PathError,
283
+ "Protocol '#{uri}' could not be resolved. " \
284
+ "Register a protocol resolver with PathExpander.register_protocol_resolver(resolver)"
285
+ end
286
+
287
+ # Expand environment variables in path
288
+ #
289
+ # @note If an environment variable is not set, the original reference
290
+ # (e.g., "$VAR" or "${VAR}") is preserved in the output. This allows
291
+ # deferred resolution or detection of missing variables by the caller.
292
+ # Callers should validate paths if undefined variables are unacceptable.
293
+ #
294
+ # @param path [String] Path containing environment variable references
295
+ # @return [String] Path with environment variables expanded
296
+ def expand_env_vars(path)
297
+ expanded = path.dup
298
+
299
+ # Handle ${VAR} format
300
+ expanded.gsub!(/\$\{([A-Z_][A-Z0-9_]*)\}/i) do |match|
301
+ var_name = match[2..-2] # Remove ${ and }
302
+ get_env(var_name) || match
303
+ end
304
+
305
+ # Handle $VAR format
306
+ expanded.gsub!(/\$([A-Z_][A-Z0-9_]*)/i) do |match|
307
+ get_env(match[1..-1]) || match
308
+ end
309
+
310
+ expanded
311
+ end
312
+ end
313
+ end
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Fs
6
+ # Base error class for all ace-support-fs errors
7
+ class Error < StandardError; end
8
+
9
+ # Raised when a path cannot be resolved
10
+ class PathError < Error; end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/path_expander"
4
+
5
+ module Ace
6
+ module Support
7
+ module Fs
8
+ module Molecules
9
+ # Traverses directory tree to find configuration directories
10
+ class DirectoryTraverser
11
+ attr_reader :config_dir
12
+
13
+ # Initialize traverser
14
+ # @param config_dir [String] Name of config directory to find (default: ".ace")
15
+ # @param start_path [String] Path to start traversal from
16
+ def initialize(config_dir: ".ace", start_path: nil)
17
+ @config_dir = config_dir
18
+ @start_path = start_path ? Atoms::PathExpander.expand(start_path) : Dir.pwd
19
+ end
20
+
21
+ # Traverse from current directory up to project root or filesystem root
22
+ # @return [Array<String>] Ordered list of directories containing config folders
23
+ def traverse
24
+ directories = []
25
+ current_path = @start_path
26
+ visited = Set.new
27
+
28
+ # Find project root
29
+ project_root = ProjectRootFinder.find(start_path: @start_path)
30
+
31
+ # Traverse up from current directory
32
+ loop do
33
+ # Avoid infinite loops
34
+ break if visited.include?(current_path)
35
+ visited.add(current_path)
36
+
37
+ # Check if config directory exists at this level
38
+ config_path = File.join(current_path, @config_dir)
39
+ directories << current_path if Dir.exist?(config_path)
40
+
41
+ # Get parent directory
42
+ parent = File.dirname(current_path)
43
+
44
+ # Stop if we've reached filesystem root
45
+ break if parent == current_path
46
+
47
+ # Stop if we've gone past project root (if it exists)
48
+ # We check after adding current_path to allow project root itself
49
+ if project_root && current_path == project_root
50
+ # Already processed project root, can stop
51
+ break
52
+ end
53
+
54
+ current_path = parent
55
+ end
56
+
57
+ directories
58
+ end
59
+
60
+ # Find all config directories in the traversal path
61
+ # @return [Array<String>] Full paths to config directories
62
+ def find_config_directories
63
+ traverse.map { |dir| File.join(dir, @config_dir) }
64
+ end
65
+
66
+ # Get the directory hierarchy from current to root
67
+ # @return [Array<String>] All directories from current to project/filesystem root
68
+ def directory_hierarchy
69
+ hierarchy = []
70
+ current_path = @start_path
71
+
72
+ # Find project root
73
+ project_root = ProjectRootFinder.find(start_path: @start_path)
74
+ stop_at = project_root || "/"
75
+
76
+ loop do
77
+ hierarchy << current_path
78
+
79
+ # Stop if we've reached our stopping point
80
+ break if current_path == stop_at
81
+
82
+ # Get parent directory
83
+ parent = File.dirname(current_path)
84
+
85
+ # Stop if we've reached filesystem root
86
+ break if parent == current_path
87
+
88
+ current_path = parent
89
+ end
90
+
91
+ hierarchy
92
+ end
93
+
94
+ # Build cascade paths with priorities
95
+ # @return [Hash<String, Integer>] Map of directory paths to priorities
96
+ def build_cascade_priorities
97
+ priorities = {}
98
+ directories = find_config_directories
99
+
100
+ # Assign priorities - closer to cwd = higher priority (lower number)
101
+ directories.each_with_index do |dir, index|
102
+ priorities[dir] = index * 10
103
+ end
104
+
105
+ # Add home directory with lower priority
106
+ home_config = File.expand_path("~/#{@config_dir}")
107
+ if Dir.exist?(home_config) && !priorities.key?(home_config)
108
+ priorities[home_config] = (directories.length + 1) * 10
109
+ end
110
+
111
+ priorities
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "../atoms/path_expander"
5
+
6
+ module Ace
7
+ module Support
8
+ module Fs
9
+ module Molecules
10
+ # Find project root directory based on markers
11
+ class ProjectRootFinder
12
+ # Common project root markers in order of preference
13
+ DEFAULT_MARKERS = %w[
14
+ .git
15
+ Gemfile
16
+ package.json
17
+ Cargo.toml
18
+ pyproject.toml
19
+ go.mod
20
+ .hg
21
+ .svn
22
+ Rakefile
23
+ Makefile
24
+ ].freeze
25
+
26
+ # Thread-safe cache for project root detection
27
+ @cache = {}
28
+ @cache_mutex = Mutex.new
29
+
30
+ class << self
31
+ attr_reader :cache_mutex
32
+
33
+ def cache
34
+ @cache ||= {}
35
+ end
36
+ end
37
+
38
+ # Initialize finder with optional custom markers
39
+ # @param markers [Array<String>] Project root markers to look for
40
+ # @param start_path [String] Path to start searching from (default: current directory)
41
+ # @raise [ArgumentError] if markers is nil or empty
42
+ def initialize(markers: DEFAULT_MARKERS, start_path: nil)
43
+ if markers.nil? || markers.empty?
44
+ raise ArgumentError, "markers cannot be nil or empty"
45
+ end
46
+ @markers = markers
47
+ @start_path = start_path ? Atoms::PathExpander.expand(start_path) : Dir.pwd
48
+ end
49
+
50
+ # Find project root directory with caching
51
+ # @return [String, nil] Project root path or nil if not found
52
+ def find
53
+ # Check environment variable first
54
+ project_root_env = env_project_root
55
+ if project_root_env && !project_root_env.empty?
56
+ project_root = Atoms::PathExpander.expand(project_root_env)
57
+ if Dir.exist?(project_root) && path_within_root?(@start_path, project_root)
58
+ return project_root
59
+ end
60
+ end
61
+
62
+ cache_key = "#{@start_path}:#{@markers.join(",")}"
63
+
64
+ # Thread-safe cache access
65
+ self.class.cache_mutex.synchronize do
66
+ # Return cached result if available
67
+ return self.class.cache[cache_key] if self.class.cache.key?(cache_key)
68
+
69
+ # Find and cache the result
70
+ result = find_without_cache
71
+ self.class.cache[cache_key] = result
72
+ result
73
+ end
74
+ end
75
+
76
+ # Find project root or fall back to current directory
77
+ # @return [String] Project root path or current directory
78
+ def find_or_current
79
+ find || Dir.pwd
80
+ end
81
+
82
+ # Check if we're in a project directory
83
+ # @return [Boolean] true if project root is found
84
+ def in_project?
85
+ !find.nil?
86
+ end
87
+
88
+ # Get the relative path from project root to a given path
89
+ # @param path [String] Path to make relative
90
+ # @return [String, nil] Relative path or nil if not in project
91
+ def relative_path(path)
92
+ root = find
93
+ return nil unless root
94
+
95
+ # Use realpath to handle symlinks and resolve paths
96
+ real_root = File.realpath(root)
97
+ real_path = File.realpath(Atoms::PathExpander.expand(path))
98
+
99
+ return nil unless real_path.start_with?(real_root)
100
+
101
+ Pathname.new(real_path).relative_path_from(Pathname.new(real_root)).to_s
102
+ end
103
+
104
+ # Clear the cache (thread-safe)
105
+ def self.clear_cache!
106
+ cache_mutex.synchronize do
107
+ cache.clear
108
+ end
109
+ end
110
+
111
+ # Class method for convenience
112
+ # @return [String, nil] Project root path
113
+ def self.find(start_path: nil, markers: DEFAULT_MARKERS)
114
+ new(start_path: start_path, markers: markers).find
115
+ end
116
+
117
+ # Class method to find or use current directory
118
+ # @return [String] Project root path or current directory
119
+ def self.find_or_current(start_path: nil, markers: DEFAULT_MARKERS)
120
+ new(start_path: start_path, markers: markers).find_or_current
121
+ end
122
+
123
+ protected
124
+
125
+ # Access PROJECT_ROOT_PATH environment variable
126
+ # Extracted to allow test stubbing without modifying global ENV
127
+ def env_project_root
128
+ ENV["PROJECT_ROOT_PATH"]
129
+ end
130
+
131
+ private
132
+
133
+ # Find project root without using cache
134
+ # @return [String, nil] Project root path or nil if not found
135
+ def find_without_cache
136
+ path = @start_path
137
+
138
+ # Traverse up the directory tree
139
+ loop do
140
+ # Check for any marker in current directory
141
+ @markers.each do |marker|
142
+ marker_path = File.join(path, marker)
143
+ return path if File.exist?(marker_path)
144
+ end
145
+
146
+ # Get parent directory
147
+ parent = File.dirname(path)
148
+
149
+ # Stop if we've reached the filesystem root
150
+ break if parent == path
151
+
152
+ path = parent
153
+ end
154
+
155
+ nil
156
+ end
157
+
158
+ def path_within_root?(candidate_path, root_path)
159
+ candidate = File.expand_path(candidate_path)
160
+ root = File.expand_path(root_path)
161
+ candidate == root || candidate.start_with?("#{root}/")
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Fs
6
+ VERSION = "0.3.0"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "fs/version"
4
+ require_relative "fs/errors"
5
+ require_relative "fs/atoms/path_expander"
6
+ require_relative "fs/molecules/project_root_finder"
7
+ require_relative "fs/molecules/directory_traverser"
8
+
9
+ module Ace
10
+ module Support
11
+ # Filesystem utilities for ace-* gems
12
+ #
13
+ # Provides unified path expansion, project root detection, and directory traversal
14
+ # functionality extracted from ace-support-core and ace-config.
15
+ #
16
+ # Components:
17
+ # - Atoms::PathExpander - Path expansion with protocol, env var, and relative path support
18
+ # - Molecules::ProjectRootFinder - Project root detection based on marker files
19
+ # - Molecules::DirectoryTraverser - Config directory discovery in directory hierarchy
20
+ module Fs
21
+ end
22
+ end
23
+ end
metadata ADDED
@@ -0,0 +1,56 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ace-support-fs
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Michal Czyz
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Infrastructure gem providing unified path expansion, project root detection,
13
+ and directory traversal functionality for ace-* gems. Library-only gem following
14
+ ace-support-* pattern for shared filesystem operations.
15
+ email:
16
+ - mc@cs3b.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - LICENSE
23
+ - README.md
24
+ - Rakefile
25
+ - lib/ace/support/fs.rb
26
+ - lib/ace/support/fs/atoms/path_expander.rb
27
+ - lib/ace/support/fs/errors.rb
28
+ - lib/ace/support/fs/molecules/directory_traverser.rb
29
+ - lib/ace/support/fs/molecules/project_root_finder.rb
30
+ - lib/ace/support/fs/version.rb
31
+ homepage: https://github.com/cs3b/ace
32
+ licenses:
33
+ - MIT
34
+ metadata:
35
+ allowed_push_host: https://rubygems.org
36
+ homepage_uri: https://github.com/cs3b/ace
37
+ source_code_uri: https://github.com/cs3b/ace/tree/main/ace-support-fs/
38
+ changelog_uri: https://github.com/cs3b/ace/blob/main/ace-support-fs/CHANGELOG.md
39
+ rdoc_options: []
40
+ require_paths:
41
+ - lib
42
+ required_ruby_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 3.2.0
47
+ required_rubygems_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '0'
52
+ requirements: []
53
+ rubygems_version: 3.6.9
54
+ specification_version: 4
55
+ summary: Filesystem utilities for ace-* gems
56
+ test_files: []