diff --git a/.specify/memory/roadmap.md b/.specify/memory/roadmap.md index 14a903b..d493843 100644 --- a/.specify/memory/roadmap.md +++ b/.specify/memory/roadmap.md @@ -1,7 +1,7 @@ # Product Roadmap: Subtree CLI -**Version:** v1.5.0 -**Last Updated:** 2025-11-27 +**Version:** v1.7.0 +**Last Updated:** 2025-11-30 ## Vision & Goals @@ -31,8 +31,10 @@ Simplify git subtree management through declarative YAML configuration with safe - ✅ Case-Insensitive Names & Validation - ✅ Extract Command (5 user stories, 411 tests) -- ⏳ **Multi-Pattern Extraction** — Multiple `--from` patterns in single extraction -- ⏳ **Extract Clean Mode** — `--clean` flag to remove extracted files safely +- ✅ Multi-Pattern Extraction (5 user stories, 439 tests) +- ✅ Extract Clean Mode (5 user stories, 477 tests) +- ✅ **Brace Expansion: Embedded Path Separators** (4 user stories, 526 tests) +- ⏳ **Multi-Destination Extraction** — Fan-out to multiple `--to` paths - ⏳ Lint Command — Configuration integrity validation ## Product-Level Metrics & Success Criteria @@ -62,7 +64,7 @@ Simplify git subtree management through declarative YAML configuration with safe 1. **Phase 1 → Phase 2**: Core operations depend on config foundation 2. **Phase 2 → Phase 3**: Extract and Lint require subtrees to exist (Add command) 3. **Phase 3 → Phase 4**: Packaging requires all commands feature-complete -4. **Multi-Pattern → Clean Mode**: Clean mode benefits from array pattern support +4. **Multi-Pattern → Brace Expansion → Multi-Destination → Lint**: Pattern enhancements before validation ## Global Risks & Assumptions @@ -78,6 +80,8 @@ Simplify git subtree management through declarative YAML configuration with safe ## Change Log +- **v1.7.0** (2025-11-30): Brace Expansion complete (011-brace-expansion) with 526 tests; embedded path separators, cartesian product, bash pass-through semantics (MINOR — feature complete) +- **v1.6.0** (2025-11-29): Added Brace Expansion and Multi-Destination Extraction to Phase 3; marked Multi-Pattern Extraction and Extract Clean Mode complete (MINOR — new features) - **v1.5.0** (2025-11-27): Roadmap refactored to multi-file structure; added Multi-Pattern Extraction and Extract Clean Mode to Phase 3 (MINOR — new features, structural improvement) - **v1.4.0** (2025-10-29): Phase 2 complete — Remove Command delivered with idempotent behavior (191 tests passing) - **v1.3.0** (2025-10-28): Phase 2 progress — Add Command and Update Command marked complete diff --git a/.specify/memory/roadmap/phase-3-advanced-operations.md b/.specify/memory/roadmap/phase-3-advanced-operations.md index 14f27e2..1ad3a89 100644 --- a/.specify/memory/roadmap/phase-3-advanced-operations.md +++ b/.specify/memory/roadmap/phase-3-advanced-operations.md @@ -1,7 +1,7 @@ # Phase 3 — Advanced Operations & Safety **Status:** ACTIVE -**Last Updated:** 2025-11-29 +**Last Updated:** 2025-11-30 ## Goal @@ -66,7 +66,38 @@ Enable portable configuration validation, selective file extraction with compreh - Continue-on-error for bulk operations with failure summary - **Delivered**: All 5 user stories (ad-hoc clean, force override, bulk clean, multi-pattern, error handling), 477 tests passing -### 5. Lint Command ⏳ PLANNED +### 5. Brace Expansion: Embedded Path Separators ✅ COMPLETE + +- **Purpose & user value**: Extends existing brace expansion (`*.{h,c}`) to support embedded path separators (e.g., `Sources/{A,B/C}.swift`), enabling extraction from directories at different depths with a single pattern +- **Success metrics**: + - Patterns like `Sources/{A,B/C}.swift` correctly match files at different directory depths + - Multiple brace groups expand as cartesian product (bash behavior) + - 100% backward compatible with existing patterns +- **Dependencies**: Multi-Pattern Extraction, Extract Command (existing GlobMatcher) +- **Notes**: + - GlobMatcher already supports basic `{a,b}` for extensions; this adds pre-expansion for path separators + - Pre-expansion at CLI level via `BraceExpander` utility (bash semantics) + - Only applies to `--from` and `--exclude` patterns (`--to` is destination path, not glob) + - Example: `Sources/Crypto/{PrettyBytes,SecureBytes,BoringSSL/RNG_boring}.swift` → 3 patterns + - Nested braces, escaping, numeric ranges deferred to backlog +- **Delivered**: All 4 user stories (basic expansion, multiple groups, pass-through, empty alternative errors), 526 tests passing + +### 6. Multi-Destination Extraction (Fan-Out) ⏳ PLANNED + +- **Purpose & user value**: Allows extracting matched files to multiple destinations simultaneously (e.g., `--to Lib/ --to Vendor/`), enabling distribution of extracted files to multiple locations without repeated commands +- **Success metrics**: + - Multiple `--to` flags supported in single command + - Each matched file copied to every `--to` destination (fan-out) + - `--from` and `--to` counts independent (no positional pairing) + - Works with all existing extract modes (ad-hoc, bulk, clean) +- **Dependencies**: Multi-Pattern Extraction +- **Notes**: + - Fan-out semantics: N files × M destinations = N×M copy operations + - Directory structure preserved at each destination + - YAML schema: `to: ["path1/", "path2/"]` for persisted mappings + - Atomic per-destination: all files to one destination succeed or fail together + +### 7. Lint Command ⏳ PLANNED - **Purpose & user value**: Validates subtree integrity and synchronization state offline and with remote checks, enabling users to detect configuration drift, missing subtrees, or desync between config and repository state - **Success metrics**: @@ -84,17 +115,19 @@ Enable portable configuration validation, selective file extraction with compreh 2. Extract Command ✅ 3. Multi-Pattern Extraction ✅ 4. Extract Clean Mode ✅ - 5. Lint Command ⏳ (final Phase 3 feature) -- **Rationale**: Lint command validates all previous operations and completes Phase 3 + 5. Brace Expansion in Patterns ✅ + 6. Multi-Destination Extraction ⏳ + 7. Lint Command ⏳ (final Phase 3 feature) +- **Rationale**: Brace Expansion and Multi-Destination extend pattern capabilities before Lint validates all operations - **Cross-phase dependencies**: Requires Phase 2 Add Command for subtrees to exist ## Phase-Specific Metrics & Success Criteria This phase is successful when: -- All five features complete and tested +- All seven features complete and tested - Extract supports multiple patterns and cleanup operations - Lint provides comprehensive integrity validation -- 475+ tests pass on macOS and Ubuntu +- 600+ tests pass on macOS and Ubuntu (currently 526, growing) ## Risks & Assumptions @@ -105,6 +138,8 @@ This phase is successful when: ## Phase Notes +- 2025-11-30: Brace Expansion complete (011-brace-expansion) with 526 tests; 4 user stories delivered +- 2025-11-29: Added Brace Expansion and Multi-Destination Extraction features - 2025-11-29: Extract Clean Mode complete (010-extract-clean) with 477 tests; dry-run/preview mode deferred to Phase 5 backlog - 2025-11-27: Added Multi-Pattern Extraction and Extract Clean Mode features before Lint Command - 2025-10-29: Case-Insensitive Names added to Phase 3 diff --git a/.specify/memory/roadmap/phase-5-backlog.md b/.specify/memory/roadmap/phase-5-backlog.md index 0c19089..d01898a 100644 --- a/.specify/memory/roadmap/phase-5-backlog.md +++ b/.specify/memory/roadmap/phase-5-backlog.md @@ -1,7 +1,7 @@ # Phase 5 — Future Features (Backlog) **Status:** FUTURE -**Last Updated:** 2025-11-27 +**Last Updated:** 2025-11-29 ## Goal @@ -91,6 +91,32 @@ Post-1.0 enhancements for advanced workflows, improved onboarding, and enterpris - **Dependencies**: Update Command - **Notes**: Configurable retry count, distinguishes transient from permanent failures +### 11. Brace Expansion: Backslash Escaping + +- **Purpose & user value**: Allow users to escape literal braces in patterns using `\{` and `\}` (bash-style escaping) +- **Success metrics**: + - Users can match files with literal `{` or `}` characters in names + - `path/\{literal\}.txt` matches file named `{literal}.txt` +- **Dependencies**: Brace Expansion (011) +- **Notes**: MVP workaround is character class syntax `[{]` and `[}]`. Backslash escaping provides more intuitive syntax. + +### 12. Brace Expansion: Nested Braces + +- **Purpose & user value**: Support nested brace patterns like `{a,{b,c}}` expanding to `a`, `b`, `c` +- **Success metrics**: + - Nested braces expand recursively matching bash behavior +- **Dependencies**: Brace Expansion (011) +- **Notes**: Adds complexity; evaluate user demand before implementing + +### 13. Brace Expansion: Numeric Ranges + +- **Purpose & user value**: Support numeric range patterns like `{1..10}` expanding to `1`, `2`, ..., `10` +- **Success metrics**: + - Numeric ranges expand to sequential numbers + - Supports zero-padding `{01..10}` → `01`, `02`, ..., `10` +- **Dependencies**: Brace Expansion (011) +- **Notes**: Bash feature; useful for numbered files but lower priority than core expansion + ## Dependencies & Sequencing - Features are independent and can be prioritized based on user demand @@ -112,4 +138,5 @@ This phase is successful when: ## Phase Notes +- 2025-11-29: Added Brace Expansion deferred features (backslash escaping, nested braces, numeric ranges) - 2025-11-27: Initial backlog created from roadmap refactor diff --git a/README.md b/README.md index 01c01df..8b5d0d1 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,15 @@ subtree extract --name mylib \ --from "src/**/*.c" \ --to vendor/ +# Brace expansion (011) - compact patterns with {alternatives} +subtree extract --name mylib --from "*.{h,c,cpp}" --to Sources/ +subtree extract --name mylib --from "{src,test}/*.swift" --to Sources/ + +# Brace expansion with embedded path separators (different directory depths) +subtree extract --name crypto-lib \ + --from "Sources/{PrettyBytes,SecureBytes,BoringSSL/RNG}.swift" \ + --to Crypto/ + # With exclusions (applies to all patterns) subtree extract --name mylib --from "src/**/*.c" --to Sources/ --exclude "**/test/**" diff --git a/Sources/SubtreeLib/Commands/ExtractCommand.swift b/Sources/SubtreeLib/Commands/ExtractCommand.swift index 879b45d..1a1978d 100644 --- a/Sources/SubtreeLib/Commands/ExtractCommand.swift +++ b/Sources/SubtreeLib/Commands/ExtractCommand.swift @@ -220,17 +220,23 @@ public struct ExtractCommand: AsyncParsableCommand { // T069: Destination path validation let normalizedDest = try validateDestination(destinationValue, gitRoot: gitRoot) + // T039: Expand brace patterns in --from before matching (011-brace-expansion) + let expandedFromPatterns = expandBracePatterns(from) + + // T040: Expand brace patterns in --exclude before matching (011-brace-expansion) + let expandedExcludePatterns = expandBracePatterns(exclude) + // T023-T025 + T040: Multi-pattern matching with deduplication and per-pattern tracking // Process all --from patterns and collect unique files var allMatchedFiles: [(sourcePath: String, relativePath: String)] = [] var seenPaths = Set() // T024: Deduplicate by relative path var patternMatchCounts: [(pattern: String, count: Int)] = [] // T040: Per-pattern tracking - for pattern in from { + for pattern in expandedFromPatterns { let matchedFiles = try await findMatchingFiles( in: subtree.prefix, pattern: pattern, - excludePatterns: exclude, + excludePatterns: expandedExcludePatterns, gitRoot: gitRoot ) @@ -569,10 +575,14 @@ public struct ExtractCommand: AsyncParsableCommand { let normalizedDest = try validateDestination(mapping.to, gitRoot: gitRoot) let fullDestPath = gitRoot + "/" + normalizedDest + // 011-brace-expansion: Expand brace patterns before matching + let expandedFromPatterns = expandBracePatterns(mapping.from) + let expandedExcludePatterns = expandBracePatterns(mapping.exclude ?? []) + // Find files to clean let filesToClean = try await findFilesToClean( - patterns: mapping.from, - excludePatterns: mapping.exclude ?? [], + patterns: expandedFromPatterns, + excludePatterns: expandedExcludePatterns, subtreePrefix: subtree.prefix, destinationPath: fullDestPath, gitRoot: gitRoot @@ -664,10 +674,14 @@ public struct ExtractCommand: AsyncParsableCommand { let normalizedDest = try validateDestination(destinationValue, gitRoot: gitRoot) let fullDestPath = gitRoot + "/" + normalizedDest + // 011-brace-expansion: Expand brace patterns before matching + let expandedFromPatterns = expandBracePatterns(from) + let expandedExcludePatterns = expandBracePatterns(exclude) + // T025: Find files to clean in destination let filesToClean = try await findFilesToClean( - patterns: from, - excludePatterns: exclude, + patterns: expandedFromPatterns, + excludePatterns: expandedExcludePatterns, subtreePrefix: subtree.prefix, destinationPath: fullDestPath, gitRoot: gitRoot @@ -828,15 +842,19 @@ public struct ExtractCommand: AsyncParsableCommand { // Validate destination let normalizedDest = try validateDestination(mapping.to, gitRoot: gitRoot) + // 011-brace-expansion: Expand brace patterns before matching + let expandedFromPatterns = expandBracePatterns(mapping.from) + let expandedExcludePatterns = expandBracePatterns(mapping.exclude ?? []) + // T026: Find matching files from ALL patterns (multi-pattern support) var allMatchedFiles: [(sourcePath: String, relativePath: String)] = [] var seenPaths = Set() // Deduplicate by relative path - for pattern in mapping.from { + for pattern in expandedFromPatterns { let matchedFiles = try await findMatchingFiles( in: subtree.prefix, pattern: pattern, - excludePatterns: mapping.exclude ?? [], + excludePatterns: expandedExcludePatterns, gitRoot: gitRoot ) @@ -982,6 +1000,40 @@ public struct ExtractCommand: AsyncParsableCommand { return trimmed.hasSuffix("/") ? String(trimmed.dropLast()) : trimmed } + // MARK: - T038: Brace Expansion Helper (011-brace-expansion) + + /// Expand brace patterns in a list of patterns + /// + /// Applies `BraceExpander` to each pattern, handling errors with user-friendly messages. + /// Returns flattened array of all expanded patterns. + /// + /// - Parameter patterns: Array of patterns potentially containing braces + /// - Returns: Array of expanded patterns + /// - Throws: Never (exits with error code on failure) + private func expandBracePatterns(_ patterns: [String]) -> [String] { + var expandedPatterns: [String] = [] + + for pattern in patterns { + do { + let expanded = try BraceExpander.expand(pattern) + expandedPatterns.append(contentsOf: expanded) + } catch BraceExpanderError.emptyAlternative(let invalidPattern) { + // T041: User-friendly error message + writeStderr("❌ Error: Invalid brace pattern '\(invalidPattern)'\n") + writeStderr(" Empty alternatives like {a,} or {,b} are not supported.\n\n") + writeStderr("Suggestions:\n") + writeStderr(" • Remove trailing/leading commas: {a,b} instead of {a,}\n") + writeStderr(" • Use separate --from flags for different patterns\n") + Foundation.exit(1) + } catch { + writeStderr("❌ Error: Failed to expand pattern '\(pattern)': \(error)\n") + Foundation.exit(1) + } + } + + return expandedPatterns + } + // MARK: - T070: Glob Pattern Matching /// Find all files matching the glob pattern diff --git a/Sources/SubtreeLib/Utilities/BraceExpander.swift b/Sources/SubtreeLib/Utilities/BraceExpander.swift new file mode 100644 index 0000000..6029bb1 --- /dev/null +++ b/Sources/SubtreeLib/Utilities/BraceExpander.swift @@ -0,0 +1,161 @@ +import Foundation + +/// Errors that can occur during brace expansion +public enum BraceExpanderError: Error, Equatable { + /// Pattern contains an empty alternative (e.g., `{a,}` or `{,b}` or `{a,,b}`) + /// Associated value is the full pattern for error reporting + case emptyAlternative(String) +} + +/// Expands brace patterns in glob expressions before matching +/// +/// Supports bash-style brace expansion with embedded path separators: +/// - `{a,b,c}` → `["a", "b", "c"]` +/// - `{a,b/c}` → `["a", "b/c"]` (embedded path separators) +/// - `{x,y}{1,2}` → `["x1", "x2", "y1", "y2"]` (cartesian product) +/// +/// Invalid patterns are passed through unchanged (bash behavior): +/// - `{a}` (no comma) → `["{a}"]` +/// - `{}` (empty) → `["{}"]` +/// - `{a,b` (unclosed) → `["{a,b"]` +/// +/// Empty alternatives throw an error (safety deviation from bash): +/// - `{a,}` → throws `BraceExpanderError.emptyAlternative` +/// +/// ## Usage +/// ```swift +/// let patterns = try BraceExpander.expand("Sources/{Foo,Bar/Baz}.swift") +/// // Returns: ["Sources/Foo.swift", "Sources/Bar/Baz.swift"] +/// ``` +public struct BraceExpander { + + // MARK: - Internal Types + + /// Represents a brace group found in a pattern + private struct BraceGroup { + /// Start index of the opening `{` + let startIndex: String.Index + /// End index of the closing `}` + let endIndex: String.Index + /// The comma-separated alternatives inside the braces + let alternatives: [String] + } + + // MARK: - Public API + + /// Expand brace patterns in a glob expression + /// + /// - Parameter pattern: Input pattern potentially containing braces + /// - Returns: Array of expanded patterns (or single-element array if no braces) + /// - Throws: `BraceExpanderError.emptyAlternative` if pattern contains `{a,}`, `{,b}`, or `{a,,b}` + public static func expand(_ pattern: String) throws -> [String] { + // Find all valid brace groups + let groups = findBraceGroups(in: pattern) + + // No valid brace groups? Return original pattern + guard !groups.isEmpty else { + return [pattern] + } + + // Expand using cartesian product of all groups + return try expandWithGroups(pattern: pattern, groups: groups) + } + + // MARK: - Private Implementation + + /// Find all valid brace groups in a pattern + /// + /// A valid brace group must: + /// - Start with `{` + /// - End with `}` + /// - Contain at least one comma (otherwise it's treated as literal) + /// + /// - Parameter pattern: The pattern to search + /// - Returns: Array of brace groups in order of appearance + private static func findBraceGroups(in pattern: String) -> [BraceGroup] { + var groups: [BraceGroup] = [] + var searchStart = pattern.startIndex + + while searchStart < pattern.endIndex { + // Find opening brace + guard let openIndex = pattern[searchStart...].firstIndex(of: "{") else { + break + } + + // Find matching closing brace + guard let closeIndex = pattern[pattern.index(after: openIndex)...].firstIndex(of: "}") else { + // Unclosed brace - no more valid groups + break + } + + // Extract content between braces + let contentStart = pattern.index(after: openIndex) + let content = String(pattern[contentStart.. [String] { + // Start with empty prefix + var results = [""] + var lastEnd = pattern.startIndex + + for group in groups { + // Add literal text between last group and this one + let prefix = String(pattern[lastEnd.. 100 { + FileHandle.standardError.write( + Data("⚠️ Warning: Brace expansion produced \(results.count) patterns (>100). Consider simplifying the pattern.\n".utf8) + ) + } + + return results + } +} diff --git a/Tests/IntegrationTests/ExtractIntegrationTests.swift b/Tests/IntegrationTests/ExtractIntegrationTests.swift index 1c2a177..e2e0b87 100644 --- a/Tests/IntegrationTests/ExtractIntegrationTests.swift +++ b/Tests/IntegrationTests/ExtractIntegrationTests.swift @@ -1665,4 +1665,173 @@ struct ExtractIntegrationTests { #expect(result.exitCode != 0, "Should fail when destination is file not directory") #expect(result.stderr.count > 0, "Should have error message") } + + // MARK: - Brace Expansion Integration Tests (011-brace-expansion T034-T036) + + // T034: Extract with embedded path separator pattern + @Test("Extract with embedded path separator pattern {A,B/C}") + func testExtractWithEmbeddedPathSeparator() async throws { + let harness = TestHarness() + let fixture = try await GitRepositoryFixture() + defer { try? fixture.tearDown() } + + // Create subtree with nested structure + let subtreePrefix = "vendor/lib" + let dirs = [ + "\(subtreePrefix)/A", + "\(subtreePrefix)/B/C" + ] + for dir in dirs { + try FileManager.default.createDirectory( + atPath: fixture.path.string + "/" + dir, + withIntermediateDirectories: true + ) + } + + // Create files at different depths + try "contentA".write(toFile: fixture.path.string + "/" + subtreePrefix + "/A/file.swift", + atomically: true, encoding: .utf8) + try "contentBC".write(toFile: fixture.path.string + "/" + subtreePrefix + "/B/C/file.swift", + atomically: true, encoding: .utf8) + + // Create config + try writeSubtreeConfig( + name: "lib", + remote: "https://example.com/lib.git", + prefix: subtreePrefix, + commit: "abc123", + to: fixture.path.string + "/subtree.yaml" + ) + + // Commit + try await fixture.runGit(["add", "."]) + try await fixture.runGit(["commit", "-m", "Initial commit"]) + + // Extract using brace expansion with embedded path separator + let result = try await harness.run( + arguments: ["extract", "--name", "lib", "--from", "{A,B/C}/*.swift", "--to", "output/"], + workingDirectory: fixture.path + ) + + #expect(result.exitCode == 0, "Should succeed with brace expansion: \(result.stderr)") + + // Verify both files were extracted + let fileAExists = FileManager.default.fileExists( + atPath: fixture.path.string + "/output/A/file.swift" + ) + let fileBCExists = FileManager.default.fileExists( + atPath: fixture.path.string + "/output/B/C/file.swift" + ) + + #expect(fileAExists, "Should extract A/file.swift") + #expect(fileBCExists, "Should extract B/C/file.swift (embedded path separator)") + } + + // T035: Extract with multiple brace groups (cartesian product) + @Test("Extract with multiple brace groups cartesian product") + func testExtractWithMultipleBraceGroups() async throws { + let harness = TestHarness() + let fixture = try await GitRepositoryFixture() + defer { try? fixture.tearDown() } + + // Create subtree with structure for cartesian product: {src,test}/{foo,bar}.swift + let subtreePrefix = "vendor/lib" + let dirs = [ + "\(subtreePrefix)/src", + "\(subtreePrefix)/test" + ] + for dir in dirs { + try FileManager.default.createDirectory( + atPath: fixture.path.string + "/" + dir, + withIntermediateDirectories: true + ) + } + + // Create 4 files: src/foo.swift, src/bar.swift, test/foo.swift, test/bar.swift + try "src-foo".write(toFile: fixture.path.string + "/" + subtreePrefix + "/src/foo.swift", + atomically: true, encoding: .utf8) + try "src-bar".write(toFile: fixture.path.string + "/" + subtreePrefix + "/src/bar.swift", + atomically: true, encoding: .utf8) + try "test-foo".write(toFile: fixture.path.string + "/" + subtreePrefix + "/test/foo.swift", + atomically: true, encoding: .utf8) + try "test-bar".write(toFile: fixture.path.string + "/" + subtreePrefix + "/test/bar.swift", + atomically: true, encoding: .utf8) + + // Create config + try writeSubtreeConfig( + name: "lib", + remote: "https://example.com/lib.git", + prefix: subtreePrefix, + commit: "abc123", + to: fixture.path.string + "/subtree.yaml" + ) + + // Commit + try await fixture.runGit(["add", "."]) + try await fixture.runGit(["commit", "-m", "Initial commit"]) + + // Extract using brace expansion with cartesian product + let result = try await harness.run( + arguments: ["extract", "--name", "lib", "--from", "{src,test}/{foo,bar}.swift", "--to", "output/"], + workingDirectory: fixture.path + ) + + #expect(result.exitCode == 0, "Should succeed with cartesian product: \(result.stderr)") + + // Verify all 4 files were extracted + let files = [ + "output/src/foo.swift", + "output/src/bar.swift", + "output/test/foo.swift", + "output/test/bar.swift" + ] + + for file in files { + let exists = FileManager.default.fileExists(atPath: fixture.path.string + "/" + file) + #expect(exists, "Should extract \(file)") + } + + // Verify stdout mentions 4 files + #expect(result.stdout.contains("4 file"), "Should report 4 files extracted") + } + + // T036: Extract error on empty alternative pattern + @Test("Extract error on empty alternative pattern") + func testExtractErrorOnEmptyAlternative() async throws { + let harness = TestHarness() + let fixture = try await GitRepositoryFixture() + defer { try? fixture.tearDown() } + + // Create minimal subtree + let subtreePrefix = "vendor/lib" + try FileManager.default.createDirectory( + atPath: fixture.path.string + "/" + subtreePrefix, + withIntermediateDirectories: true + ) + try "content".write(toFile: fixture.path.string + "/" + subtreePrefix + "/file.swift", + atomically: true, encoding: .utf8) + + // Create config + try writeSubtreeConfig( + name: "lib", + remote: "https://example.com/lib.git", + prefix: subtreePrefix, + commit: "abc123", + to: fixture.path.string + "/subtree.yaml" + ) + + // Commit + try await fixture.runGit(["add", "."]) + try await fixture.runGit(["commit", "-m", "Initial commit"]) + + // Try to extract using pattern with empty alternative + let result = try await harness.run( + arguments: ["extract", "--name", "lib", "--from", "{a,}/*.swift", "--to", "output/"], + workingDirectory: fixture.path + ) + + #expect(result.exitCode != 0, "Should fail with empty alternative pattern") + #expect(result.stderr.contains("empty") || result.stderr.contains("Empty"), + "Error should mention empty alternative: \(result.stderr)") + } } diff --git a/Tests/SubtreeLibTests/Utilities/BraceExpanderTests.swift b/Tests/SubtreeLibTests/Utilities/BraceExpanderTests.swift new file mode 100644 index 0000000..857751e --- /dev/null +++ b/Tests/SubtreeLibTests/Utilities/BraceExpanderTests.swift @@ -0,0 +1,355 @@ +import Foundation +import Testing +@testable import SubtreeLib + +/// Tests for BraceExpander utility (011-brace-expansion) +/// +/// Test organization follows user stories from spec.md: +/// - US1: Basic brace expansion ({a,b}, {a,b/c}) +/// - US2: Multiple brace groups (cartesian product) +/// - US3: Pass-through for invalid patterns +/// - US4: Error on empty alternatives +@Suite("BraceExpander Tests") +struct BraceExpanderTests { + + // MARK: - Phase 2: Foundational Tests (T004) + + @Test("Detects single brace group with comma") + func detectsSingleBraceGroup() throws { + // A valid brace group has: opening {, at least one comma, closing } + let result = try BraceExpander.expand("{a,b}") + #expect(result.count == 2, "Should detect brace group and expand to 2 patterns") + } + + @Test("Detects brace group in middle of pattern") + func detectsBraceGroupInMiddle() throws { + let result = try BraceExpander.expand("prefix{a,b}suffix") + #expect(result.count == 2, "Should detect brace group even with prefix/suffix") + } + + @Test("Detects multiple brace groups") + func detectsMultipleBraceGroups() throws { + let result = try BraceExpander.expand("{a,b}{1,2}") + #expect(result.count == 4, "Should detect both brace groups (2 × 2 = 4)") + } + + @Test("No brace group when no braces") + func noBraceGroupWhenNoBraces() throws { + let result = try BraceExpander.expand("plain.txt") + #expect(result == ["plain.txt"], "Should return original when no braces") + } + + @Test("No brace group when no comma inside braces") + func noBraceGroupWhenNoComma() throws { + let result = try BraceExpander.expand("{single}") + #expect(result == ["{single}"], "Should treat as literal when no comma") + } + + // MARK: - US1: Basic Brace Expansion Tests (T008-T010) + + // T008: Basic expansion tests + @Test("Basic expansion {a,b} returns two alternatives") + func basicExpansionTwoAlternatives() throws { + let result = try BraceExpander.expand("{a,b}") + #expect(result == ["a", "b"], "Should expand to exact alternatives") + } + + @Test("Basic expansion {a,b,c} returns three alternatives") + func basicExpansionThreeAlternatives() throws { + let result = try BraceExpander.expand("{a,b,c}") + #expect(result == ["a", "b", "c"], "Should expand to all alternatives") + } + + @Test("Basic expansion preserves order") + func basicExpansionPreservesOrder() throws { + let result = try BraceExpander.expand("{z,a,m}") + #expect(result == ["z", "a", "m"], "Should preserve original order") + } + + // T009: Embedded path separator tests + @Test("Embedded path separator {a,b/c} expands correctly") + func embeddedPathSeparator() throws { + let result = try BraceExpander.expand("{a,b/c}") + #expect(result == ["a", "b/c"], "Should support path separators inside braces") + } + + @Test("Embedded deep path {a,b/c/d} expands correctly") + func embeddedDeepPath() throws { + let result = try BraceExpander.expand("{a,b/c/d}") + #expect(result == ["a", "b/c/d"], "Should support deep paths inside braces") + } + + @Test("Real-world pattern Sources/{A,B/C}.swift") + func realWorldEmbeddedPath() throws { + let result = try BraceExpander.expand("Sources/{A,B/C}.swift") + #expect(result == ["Sources/A.swift", "Sources/B/C.swift"], "Should handle real-world embedded paths") + } + + // T010: Patterns with prefix/suffix tests + @Test("Pattern with prefix and suffix") + func patternWithPrefixAndSuffix() throws { + let result = try BraceExpander.expand("prefix{a,b}suffix") + #expect(result == ["prefixasuffix", "prefixbsuffix"], "Should preserve prefix and suffix") + } + + @Test("File extension pattern *.{h,c}") + func fileExtensionPattern() throws { + let result = try BraceExpander.expand("*.{h,c}") + #expect(result == ["*.h", "*.c"], "Should expand file extension patterns") + } + + @Test("Directory pattern {src,test}/*.swift") + func directoryPattern() throws { + let result = try BraceExpander.expand("{src,test}/*.swift") + #expect(result == ["src/*.swift", "test/*.swift"], "Should expand directory patterns") + } + + @Test("Complex real-world pattern") + func complexRealWorldPattern() throws { + let result = try BraceExpander.expand("Sources/Crypto/Util/{PrettyBytes,SecureBytes}.swift") + #expect(result == [ + "Sources/Crypto/Util/PrettyBytes.swift", + "Sources/Crypto/Util/SecureBytes.swift" + ], "Should handle complex real-world patterns") + } + + // MARK: - US2: Multiple Brace Groups Tests (T014-T015) + + // T014: Two brace groups cartesian product + @Test("Two brace groups {a,b}{1,2} produces 4 patterns") + func twoBraceGroupsCartesian() throws { + let result = try BraceExpander.expand("{a,b}{1,2}") + #expect(result == ["a1", "a2", "b1", "b2"], "Should produce cartesian product") + } + + @Test("Two brace groups with prefix/suffix") + func twoBraceGroupsWithContext() throws { + let result = try BraceExpander.expand("pre{a,b}mid{1,2}post") + #expect(result == ["preamid1post", "preamid2post", "prebmid1post", "prebmid2post"]) + } + + @Test("Two brace groups in path pattern") + func twoBraceGroupsPath() throws { + let result = try BraceExpander.expand("{Sources,Tests}/{Foo,Bar}.swift") + #expect(result == [ + "Sources/Foo.swift", + "Sources/Bar.swift", + "Tests/Foo.swift", + "Tests/Bar.swift" + ], "Should expand directory × filename") + } + + @Test("Mixed sizes {a,b,c}{1,2} produces 6 patterns") + func mixedSizeBraceGroups() throws { + let result = try BraceExpander.expand("{a,b,c}{1,2}") + #expect(result.count == 6, "Should produce 3 × 2 = 6 patterns") + #expect(result == ["a1", "a2", "b1", "b2", "c1", "c2"]) + } + + // T015: Three brace groups (8 patterns) + @Test("Three brace groups {x,y}{a,b}{1,2} produces 8 patterns") + func threeBraceGroupsCartesian() throws { + let result = try BraceExpander.expand("{x,y}{a,b}{1,2}") + #expect(result.count == 8, "Should produce 2 × 2 × 2 = 8 patterns") + #expect(result == ["xa1", "xa2", "xb1", "xb2", "ya1", "ya2", "yb1", "yb2"]) + } + + @Test("Three brace groups in real-world pattern") + func threeBraceGroupsRealWorld() throws { + let result = try BraceExpander.expand("{src,test}/{foo,bar}.{h,c}") + #expect(result.count == 8, "Should produce 2 × 2 × 2 = 8 patterns") + } + + @Test("Multiple groups with embedded path separator") + func multipleGroupsWithEmbeddedPath() throws { + let result = try BraceExpander.expand("{A,B/C}{1,2}") + #expect(result == ["A1", "A2", "B/C1", "B/C2"], "Should handle embedded paths in multi-group") + } + + // MARK: - US3: Pass-Through Tests (T020-T023) + + // T020: No-comma pass-through tests + @Test("Single alternative {a} passes through unchanged") + func singleAlternativePassThrough() throws { + let result = try BraceExpander.expand("{a}") + #expect(result == ["{a}"], "No comma means no expansion") + } + + @Test("Single alternative with path {foo/bar} passes through") + func singleAlternativeWithPath() throws { + let result = try BraceExpander.expand("{foo/bar}") + #expect(result == ["{foo/bar}"], "No comma means no expansion even with path") + } + + @Test("Single alternative in context pre{x}post passes through") + func singleAlternativeInContext() throws { + let result = try BraceExpander.expand("pre{x}post") + #expect(result == ["pre{x}post"], "No comma means literal braces preserved") + } + + // T021: Empty braces pass-through tests + @Test("Empty braces {} passes through unchanged") + func emptyBracesPassThrough() throws { + let result = try BraceExpander.expand("{}") + #expect(result == ["{}"], "Empty braces treated as literal") + } + + @Test("Empty braces in context pre{}post passes through") + func emptyBracesInContext() throws { + let result = try BraceExpander.expand("pre{}post") + #expect(result == ["pre{}post"], "Empty braces preserved in context") + } + + // T022: Unclosed braces pass-through tests + @Test("Unclosed brace {a,b passes through unchanged") + func unclosedBracePassThrough() throws { + let result = try BraceExpander.expand("{a,b") + #expect(result == ["{a,b"], "Unclosed brace treated as literal") + } + + @Test("Unclosed brace in context pre{a,b passes through") + func unclosedBraceInContext() throws { + let result = try BraceExpander.expand("pre{a,b") + #expect(result == ["pre{a,b"], "Unclosed brace preserved in context") + } + + @Test("Only closing brace passes through") + func onlyClosingBrace() throws { + let result = try BraceExpander.expand("a,b}") + #expect(result == ["a,b}"], "Only closing brace treated as literal") + } + + // T023: No-braces pass-through tests + @Test("Plain text without braces passes through") + func plainTextPassThrough() throws { + let result = try BraceExpander.expand("plain.txt") + #expect(result == ["plain.txt"], "No braces means no change") + } + + @Test("Path without braces passes through") + func pathWithoutBraces() throws { + let result = try BraceExpander.expand("Sources/Foo/Bar.swift") + #expect(result == ["Sources/Foo/Bar.swift"], "Path without braces unchanged") + } + + @Test("Glob pattern without braces passes through") + func globWithoutBraces() throws { + let result = try BraceExpander.expand("**/*.swift") + #expect(result == ["**/*.swift"], "Glob wildcards preserved") + } + + @Test("Empty string passes through") + func emptyStringPassThrough() throws { + let result = try BraceExpander.expand("") + #expect(result == [""], "Empty string returns empty string in array") + } + + // MARK: - US4: Empty Alternative Error Tests (T027-T029) + + // T027: Trailing empty alternative error tests + @Test("Trailing empty alternative {a,} throws error") + func trailingEmptyAlternativeThrows() throws { + #expect(throws: BraceExpanderError.emptyAlternative("{a,}")) { + try BraceExpander.expand("{a,}") + } + } + + @Test("Trailing empty alternative in context throws error") + func trailingEmptyInContextThrows() throws { + #expect(throws: BraceExpanderError.emptyAlternative("pre{a,}post")) { + try BraceExpander.expand("pre{a,}post") + } + } + + @Test("Trailing empty with multiple alternatives {a,b,} throws") + func trailingEmptyMultipleThrows() throws { + #expect(throws: BraceExpanderError.emptyAlternative("{a,b,}")) { + try BraceExpander.expand("{a,b,}") + } + } + + // T028: Leading empty alternative error tests + @Test("Leading empty alternative {,b} throws error") + func leadingEmptyAlternativeThrows() throws { + #expect(throws: BraceExpanderError.emptyAlternative("{,b}")) { + try BraceExpander.expand("{,b}") + } + } + + @Test("Leading empty alternative in context throws error") + func leadingEmptyInContextThrows() throws { + #expect(throws: BraceExpanderError.emptyAlternative("pre{,b}post")) { + try BraceExpander.expand("pre{,b}post") + } + } + + @Test("Leading empty with multiple alternatives {,a,b} throws") + func leadingEmptyMultipleThrows() throws { + #expect(throws: BraceExpanderError.emptyAlternative("{,a,b}")) { + try BraceExpander.expand("{,a,b}") + } + } + + // T029: Middle empty alternative error tests + @Test("Middle empty alternative {a,,b} throws error") + func middleEmptyAlternativeThrows() throws { + #expect(throws: BraceExpanderError.emptyAlternative("{a,,b}")) { + try BraceExpander.expand("{a,,b}") + } + } + + @Test("Middle empty alternative in context throws error") + func middleEmptyInContextThrows() throws { + #expect(throws: BraceExpanderError.emptyAlternative("pre{a,,b}post")) { + try BraceExpander.expand("pre{a,,b}post") + } + } + + @Test("Multiple middle empties {a,,,b} throws error") + func multipleMiddleEmptiesThrows() throws { + #expect(throws: BraceExpanderError.emptyAlternative("{a,,,b}")) { + try BraceExpander.expand("{a,,,b}") + } + } + + @Test("Empty alternative in second brace group throws") + func emptyInSecondGroupThrows() throws { + #expect(throws: BraceExpanderError.emptyAlternative("{a,b}{c,}")) { + try BraceExpander.expand("{a,b}{c,}") + } + } + + // MARK: - Performance Tests (T046) + + @Test("Expansion completes in <10ms for 3 brace groups (NFR-001)") + func performanceThreeBraceGroups() throws { + // Pattern with 3 brace groups: 2 × 2 × 2 = 8 patterns + let pattern = "{src,test}/{foo,bar}/{a,b}.swift" + + let start = Date() + let result = try BraceExpander.expand(pattern) + let elapsed = Date().timeIntervalSince(start) + + // Verify correctness + #expect(result.count == 8, "Should produce 8 patterns from 2×2×2") + + // Verify performance (NFR-001: <10ms) + #expect(elapsed < 0.010, "Should complete in <10ms, took \(elapsed * 1000)ms") + } + + @Test("Expansion handles 10 alternatives per group efficiently") + func performanceTenAlternatives() throws { + // Pattern with 10 alternatives (within typical usage) + let pattern = "{a,b,c,d,e,f,g,h,i,j}{1,2,3}" + + let start = Date() + let result = try BraceExpander.expand(pattern) + let elapsed = Date().timeIntervalSince(start) + + // Verify correctness + #expect(result.count == 30, "Should produce 10 × 3 = 30 patterns") + + // Verify performance + #expect(elapsed < 0.010, "Should complete in <10ms, took \(elapsed * 1000)ms") + } +} diff --git a/agents.md b/agents.md index e56e438..f14b18a 100644 --- a/agents.md +++ b/agents.md @@ -1,12 +1,12 @@ # AI Agent Guide: Subtree CLI -**Last Updated**: 2025-11-29 | **Phase**: 010-extract-clean (Complete) | **Status**: Production-ready with Extract Clean Mode +**Last Updated**: 2025-11-30 | **Phase**: 011-brace-expansion (Complete) | **Status**: Production-ready with Brace Expansion ## What This Project Is A Swift 6.1 command-line tool for managing git subtrees with declarative YAML configuration. Think "git submodule" but with subtrees, plus automatic config tracking and file extraction. -**Current Reality**: Init + Add + Remove + Update + Extract (with clean mode) commands complete - Production-ready with 477 passing tests. +**Current Reality**: Init + Add + Remove + Update + Extract (with clean mode + brace expansion) commands complete - Production-ready with 526 passing tests. ## Current State (5 Commands Complete) @@ -20,7 +20,7 @@ A Swift 6.1 command-line tool for managing git subtrees with declarative YAML co - **Extract command** (PRODUCTION-READY - extract files with glob patterns, persistent mappings, bulk execution, clean mode) - **1 stub command** (validate - prints "not yet implemented") - **Full CLI** (`subtree --help`, all command help screens work perfectly) -- **Test suite** (477/477 tests pass: comprehensive integration + unit tests) +- **Test suite** (526/526 tests pass: comprehensive integration + unit tests) - **Git test fixtures** (GitRepositoryFixture with UUID-based temp directories, async) - **Git verification helpers** (TestHarness for CLI execution, git state validation) - **Test infrastructure** (TestHarness with swift-subprocess, async/await, black-box testing) @@ -141,8 +141,27 @@ A Swift 6.1 command-line tool for managing git subtrees with declarative YAML co - Clear error messages with actionable suggestions - Appropriate exit codes (0=success, 1=validation, 2=user error, 3=I/O) +### ✅ Brace Expansion Features (Complete - 4 User Stories) +**US1 - Basic Expansion**: +- `{a,b}` expands to multiple patterns +- Embedded path separators: `{A,B/C}` works correctly +- 100% backward compatible with existing patterns + +**US2 - Multiple Brace Groups**: +- Cartesian product: `{a,b}{1,2}` → 4 patterns +- Warning when >100 patterns generated + +**US3 - Pass-Through**: +- Malformed patterns treated as literals (bash behavior) +- `{a}`, `{}`, unclosed braces pass through unchanged + +**US4 - Safety**: +- Empty alternatives (`{a,}`) produce clear error +- User-friendly error messages with suggestions + ### ⏳ What's Next - Implement lint/validate command +- Multi-destination extraction (fan-out) - Additional enhancements and polish ## Architecture Overview @@ -250,6 +269,10 @@ This project follows **strict constitutional governance**. Every feature: - Phase 3-4: Ad-hoc clean + Force override (MVP) ✅ - Phase 5-6: Bulk clean + Multi-pattern clean ✅ - Phase 7-8: Error handling + Polish ✅ +- **Brace Expansion (011)**: All 4 user stories complete ✅ + - Phase 1-2: Setup + Foundational (BraceExpander utility) ✅ + - Phase 3-6: Basic expansion, multiple groups, pass-through, errors ✅ + - Phase 7-8: Integration + Polish (526 tests) ✅ **Keep synchronized with**: - README.md (status, build instructions, usage examples) @@ -261,4 +284,4 @@ This project follows **strict constitutional governance**. Every feature: **For Humans**: See README.md **For Windsurf**: See .windsurf/rules/ (architecture, ci-cd, compliance) **For Governance**: See .specify/memory/constitution.md -**For Requirements**: See specs/010-extract-clean/spec.md (latest feature) +**For Requirements**: See specs/011-brace-expansion/spec.md (latest feature) diff --git a/specs/011-brace-expansion/checklists/requirements.md b/specs/011-brace-expansion/checklists/requirements.md new file mode 100644 index 0000000..dcdc61a --- /dev/null +++ b/specs/011-brace-expansion/checklists/requirements.md @@ -0,0 +1,51 @@ +# Specification Quality Checklist: Brace Expansion with Embedded Path Separators + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2025-11-29 +**Updated**: 2025-11-29 (scope reduced after clarify session) +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Validation Summary + +| Category | Status | +|----------|--------| +| Content Quality | ✅ PASS | +| Requirement Completeness | ✅ PASS | +| Feature Readiness | ✅ PASS | + +## Notes + +- **Scope reduced** from 5 to 4 user stories after clarify session +- Removed `--to` brace expansion (not applicable — `--to` is destination path, not glob pattern) +- Clarified this EXTENDS existing `{a,b}` support to handle `{a,b/c}` with embedded path separators +- GlobMatcher already supports basic brace expansion; this spec adds pre-expansion for path separators +- Out-of-scope items (nested braces, escaping, numeric ranges) clearly deferred to backlog +- All clarifications resolved: + - Multiple brace groups: Cartesian product (bash behavior) + - Escaping: Deferred to backlog; character class workaround documented + - Malformed patterns: Bash-like pass-through with safety error for empty alternatives diff --git a/specs/011-brace-expansion/contracts/brace-expander-contract.md b/specs/011-brace-expansion/contracts/brace-expander-contract.md new file mode 100644 index 0000000..ea2cd63 --- /dev/null +++ b/specs/011-brace-expansion/contracts/brace-expander-contract.md @@ -0,0 +1,118 @@ +# Contract: BraceExpander + +**Feature**: 011-brace-expansion +**Date**: 2025-11-29 + +## Public API + +### BraceExpander.expand(_:) + +```swift +/// Expand brace patterns in a glob expression +/// +/// Supports bash-style brace expansion with embedded path separators: +/// - `{a,b,c}` → `["a", "b", "c"]` +/// - `{a,b/c}` → `["a", "b/c"]` +/// - `{x,y}{1,2}` → `["x1", "x2", "y1", "y2"]` (cartesian product) +/// +/// Invalid patterns are passed through unchanged (bash behavior): +/// - `{a}` (no comma) → `["{a}"]` +/// - `{}` (empty) → `["{}"]` +/// - `{a,b` (unclosed) → `["{a,b"]` +/// +/// - Parameter pattern: Input pattern potentially containing braces +/// - Returns: Array of expanded patterns +/// - Throws: `BraceExpanderError.emptyAlternative` if pattern contains `{a,}`, `{,b}`, or `{a,,b}` +public static func expand(_ pattern: String) throws -> [String] +``` + +### BraceExpanderError + +```swift +/// Errors that can occur during brace expansion +public enum BraceExpanderError: Error, Equatable { + /// Pattern contains an empty alternative (e.g., `{a,}` or `{,b}`) + /// Associated value is the full pattern for error reporting + case emptyAlternative(String) +} +``` + +## Contract Tests + +### Expansion Behavior + +| Input | Expected Output | Test ID | +|-------|-----------------|---------| +| `{a,b}` | `["a", "b"]` | CE-001 | +| `{a,b,c}` | `["a", "b", "c"]` | CE-002 | +| `{a,b/c}` | `["a", "b/c"]` | CE-003 | +| `*.{h,c}` | `["*.h", "*.c"]` | CE-004 | +| `{src,test}/*.swift` | `["src/*.swift", "test/*.swift"]` | CE-005 | +| `{a,b}{1,2}` | `["a1", "a2", "b1", "b2"]` | CE-006 | +| `{x,y}{a,b}{1,2}` | 8 patterns (cartesian) | CE-007 | + +### Pass-Through Behavior + +| Input | Expected Output | Test ID | +|-------|-----------------|---------| +| `plain.txt` | `["plain.txt"]` | CE-010 | +| `*.swift` | `["*.swift"]` | CE-011 | +| `{a}` | `["{a}"]` | CE-012 | +| `{}` | `["{}"]` | CE-013 | +| `{a,b` | `["{a,b"]` | CE-014 | +| `a,b}` | `["a,b}"]` | CE-015 | + +### Error Behavior + +| Input | Expected Error | Test ID | +|-------|----------------|---------| +| `{a,}` | `.emptyAlternative("{a,}")` | CE-020 | +| `{,b}` | `.emptyAlternative("{,b}")` | CE-021 | +| `{a,,b}` | `.emptyAlternative("{a,,b}")` | CE-022 | +| `src/{,test}/*.c` | `.emptyAlternative` | CE-023 | + +### Edge Cases + +| Input | Expected Output | Test ID | +|-------|-----------------|---------| +| `{a,b}/{c,d/e}.swift` | 4 patterns with mixed depths | CE-030 | +| `{Sources,Tests/Unit}/**/*.swift` | 2 patterns | CE-031 | +| `{{a,b}}` | `["{a}", "{b}"]` (outer braces, inner literal) | CE-032 | +| (empty string) | `[""]` | CE-033 | +| `{a,b,c,d,e,f,g,h,i,j}` | 10 patterns | CE-034 | + +## Integration Contract + +### ExtractCommand Integration + +When `ExtractCommand` receives patterns with braces: + +1. **Before file matching**: Call `BraceExpander.expand()` on each `--from` pattern +2. **Before file matching**: Call `BraceExpander.expand()` on each `--exclude` pattern +3. **For each expanded pattern**: Pass to existing `GlobMatcher` logic +4. **Deduplicate results**: Union of all matches (existing behavior) + +```swift +// Pseudocode for integration +func matchFiles(patterns: [String], excludes: [String]) throws -> [String] { + var expandedPatterns: [String] = [] + for pattern in patterns { + expandedPatterns.append(contentsOf: try BraceExpander.expand(pattern)) + } + + var expandedExcludes: [String] = [] + for exclude in excludes { + expandedExcludes.append(contentsOf: try BraceExpander.expand(exclude)) + } + + // Existing matching logic with expanded patterns + return matchWithGlobMatcher(expandedPatterns, excluding: expandedExcludes) +} +``` + +## Backward Compatibility + +- Patterns without braces MUST return unchanged (single-element array) +- Existing GlobMatcher brace expansion (for file extensions) continues to work +- No changes to subtree.yaml schema +- No changes to CLI flags diff --git a/specs/011-brace-expansion/data-model.md b/specs/011-brace-expansion/data-model.md new file mode 100644 index 0000000..5d1a070 --- /dev/null +++ b/specs/011-brace-expansion/data-model.md @@ -0,0 +1,118 @@ +# Data Model: Brace Expansion + +**Feature**: 011-brace-expansion +**Date**: 2025-11-29 + +## Entities + +### BraceExpander + +A stateless utility for expanding brace patterns in glob expressions. + +**Type**: `public struct BraceExpander` + +**Purpose**: Pre-expand patterns like `{a,b,c}` and `{a,b/c}` before glob matching + +**API**: +```swift +public struct BraceExpander { + /// Expand brace patterns in a glob expression + /// - Parameter pattern: Input pattern potentially containing braces + /// - Returns: Array of expanded patterns (or single-element array if no braces) + /// - Throws: BraceExpanderError.emptyAlternative if pattern contains {a,} or {,b} + public static func expand(_ pattern: String) throws -> [String] +} +``` + +### BraceExpanderError + +Error type for brace expansion failures. + +**Type**: `public enum BraceExpanderError: Error, Equatable` + +**Cases**: +| Case | Description | Example | +|------|-------------|---------| +| `emptyAlternative(String)` | Pattern contains empty alternative | `{a,}`, `{,b}`, `{a,,b}` | + +**Rationale**: Only one error case needed. Invalid braces (unclosed, single-item, empty braces) are passed through as literals per bash semantics. + +### BraceGroup (Internal) + +Represents a single brace expression found during parsing. + +**Type**: `private struct BraceGroup` (internal to BraceExpander) + +**Fields**: +| Field | Type | Description | +|-------|------|-------------| +| `startIndex` | `String.Index` | Position of `{` in pattern | +| `endIndex` | `String.Index` | Position of `}` in pattern | +| `alternatives` | `[String]` | Comma-separated values inside braces | + +## Data Flow + +``` +Input Pattern + │ + ▼ +┌─────────────────┐ +│ BraceExpander │ +│ .expand() │ +└─────────────────┘ + │ + ▼ +Parse brace groups + │ + ├── No valid groups found? + │ │ + │ ▼ + │ Return [original pattern] + │ + ├── Empty alternative detected? + │ │ + │ ▼ + │ throw BraceExpanderError.emptyAlternative + │ + ▼ +Cartesian product expansion + │ + ▼ +[Expanded Patterns] + │ + ▼ +GlobMatcher (existing) +``` + +## Validation Rules + +### Valid Patterns (expand) +- `{a,b}` → `["a", "b"]` +- `{a,b,c}` → `["a", "b", "c"]` +- `{a,b/c}` → `["a", "b/c"]` (embedded separator) +- `*.{h,c}` → `["*.h", "*.c"]` +- `{src,test}/*.swift` → `["src/*.swift", "test/*.swift"]` +- `{a,b}{1,2}` → `["a1", "a2", "b1", "b2"]` (cartesian product) + +### Literal Pass-Through (no expansion) +- `{a}` → `["{a}"]` (no comma) +- `{}` → `["{}"]` (empty braces) +- `{a,b` → `["{a,b"]` (unclosed) +- `a,b}` → `["a,b}"]` (no opening) +- `plain.txt` → `["plain.txt"]` (no braces) + +### Error (throw) +- `{a,}` → throw `.emptyAlternative("{a,}")` +- `{,b}` → throw `.emptyAlternative("{,b}")` +- `{a,,b}` → throw `.emptyAlternative("{a,,b}")` + +## State Transitions + +N/A — BraceExpander is stateless. Each call to `expand()` is independent. + +## Scale Considerations + +- **Warning threshold**: >100 expanded patterns triggers warning to stderr +- **No hard limit**: Large expansions allowed but discouraged +- **Memory**: O(n) where n = number of expanded patterns +- **Time**: O(n × m) where m = pattern length diff --git a/specs/011-brace-expansion/plan.md b/specs/011-brace-expansion/plan.md new file mode 100644 index 0000000..c4c7b30 --- /dev/null +++ b/specs/011-brace-expansion/plan.md @@ -0,0 +1,97 @@ +# Implementation Plan: Brace Expansion with Embedded Path Separators + +**Branch**: `011-brace-expansion` | **Date**: 2025-11-29 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/011-brace-expansion/spec.md` + +## Summary + +Extend the existing brace expansion syntax to support embedded path separators (e.g., `{a,b/c}`) by implementing a `BraceExpander` utility that pre-expands patterns BEFORE they reach GlobMatcher. This addresses the limitation where patterns like `Sources/{A,B/C}.swift` fail because GlobMatcher splits patterns by `/` before processing braces. + +**Technical Approach**: Create a standalone `BraceExpander` utility in `Sources/SubtreeLib/Utilities/` that expands brace patterns following bash semantics (cartesian product for multiple groups). Integrate into `ExtractCommand` to expand `--from` and `--exclude` patterns before passing to file matching logic. + +## Technical Context + +**Language/Version**: Swift 6.1 +**Primary Dependencies**: swift-argument-parser 1.6.1, Foundation (no new dependencies) +**Storage**: N/A (pure string transformation) +**Testing**: Swift Testing (built into Swift 6.1 toolchain) +**Target Platform**: macOS 13+, Ubuntu 20.04 LTS +**Project Type**: Single CLI project (Library + Executable pattern) +**Performance Goals**: <10ms for typical patterns (per NFR-001: ≤10 alternatives, ≤3 brace groups) +**Constraints**: 100% backward compatible with existing patterns +**Scale/Scope**: Pattern expansion only; no filesystem access in BraceExpander + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Spec-First & TDD | ✅ | Spec complete, tests will be written first per TDD | +| II. Config as Source of Truth | ✅ | N/A — brace expansion is CLI pattern syntax, not config | +| III. Safe by Default | ✅ | Invalid patterns passed through unchanged; error only on empty alternatives | +| IV. Performance by Default | ✅ | <10ms target; warn at 100+ expanded patterns | +| V. Security & Privacy | ✅ | No shell execution; pure string manipulation | +| VI. Open Source Excellence | ✅ | KISS: single utility class, bash-compatible semantics | + +**Legend**: ✅ Pass | ⬜ Not yet verified | ❌ Violation (requires justification) + +## Project Structure + +### Documentation (this feature) + +```text +specs/011-brace-expansion/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +│ └── brace-expander-contract.md +└── tasks.md # Phase 2 output (/speckit.tasks) +``` + +### Source Code (repository root) + +```text +Sources/ +├── SubtreeLib/ +│ ├── Commands/ +│ │ └── ExtractCommand.swift # Integration point (expand patterns before matching) +│ └── Utilities/ +│ ├── GlobMatcher.swift # Existing (unchanged) +│ └── BraceExpander.swift # NEW: Pattern pre-expansion utility +└── subtree/ + └── EntryPoint.swift # Unchanged + +Tests/ +├── SubtreeLibTests/ +│ └── Utilities/ +│ ├── GlobMatcherTests.swift # Existing (unchanged) +│ └── BraceExpanderTests.swift # NEW: Unit tests +└── IntegrationTests/ + └── ExtractIntegrationTests.swift # Add brace expansion integration tests +``` + +**Structure Decision**: Follows existing Library + Executable pattern. BraceExpander added to `Utilities/` alongside GlobMatcher. Integration in ExtractCommand keeps the change localized. + +## Design Decisions + +### Integration Point +- **Decision**: Expand patterns inside `ExtractCommand` before calling file matching logic +- **Rationale**: Keeps GlobMatcher unchanged; localizes changes to extract flow +- **Alternatives Rejected**: Modifying GlobMatcher would risk regressions in well-tested code + +### Testing Strategy +- **Decision**: Unit tests for BraceExpander + integration tests for ExtractCommand +- **Rationale**: Matches project's two-layer testing pattern (SubtreeLibTests + IntegrationTests) +- **Test Coverage**: Edge cases (empty, malformed, nested), cartesian product, embedded separators + +### API Design +- **Decision**: `func expand(_ pattern: String) throws -> [String]` with `BraceExpanderError` +- **Rationale**: Matches existing GlobMatcherError pattern; clear error handling for empty alternatives +- **Error Cases**: `.emptyAlternative(pattern:)` for `{a,}`, `{,b}`, `{a,,b}` + +## Complexity Tracking + +> No violations — feature is straightforward utility addition. diff --git a/specs/011-brace-expansion/quickstart.md b/specs/011-brace-expansion/quickstart.md new file mode 100644 index 0000000..766dbb2 --- /dev/null +++ b/specs/011-brace-expansion/quickstart.md @@ -0,0 +1,123 @@ +# Quickstart: Brace Expansion with Embedded Path Separators + +**Feature**: 011-brace-expansion +**Date**: 2025-11-29 + +## Prerequisites + +- Swift 6.1 toolchain installed +- Repository cloned and on `011-brace-expansion` branch + +## Build & Test + +```bash +# Build the project +swift build + +# Run all tests (should pass before starting implementation) +swift test + +# Run only BraceExpander tests (after creating test file) +swift test --filter BraceExpanderTests +``` + +## Implementation Order + +### Phase 1: BraceExpander Utility (TDD) + +1. **Create test file** (write tests first): + ``` + Tests/SubtreeLibTests/Utilities/BraceExpanderTests.swift + ``` + +2. **Create implementation file**: + ``` + Sources/SubtreeLib/Utilities/BraceExpander.swift + ``` + +3. **Test cycle**: + ```bash + # Run tests - should fail initially + swift test --filter BraceExpanderTests + + # Implement minimal code to pass + # Repeat until all tests pass + ``` + +### Phase 2: ExtractCommand Integration + +1. **Add integration tests** to `ExtractIntegrationTests.swift` + +2. **Modify ExtractCommand.swift** to expand patterns + +3. **Run full test suite**: + ```bash + swift test + ``` + +## Verification Commands + +### Unit Test Verification + +```bash +# All BraceExpander tests pass +swift test --filter BraceExpanderTests 2>&1 | grep -E "(Test|passed|failed)" + +# Expected: All tests passed +``` + +### Integration Test Verification + +```bash +# Build release binary +swift build -c release + +# Test brace expansion with extract command +.build/release/subtree extract --name test-lib \ + --from 'Sources/{Foo,Bar/Baz}.swift' \ + --to extracted/ + +# Verify expanded patterns work +``` + +### Backward Compatibility Check + +```bash +# Ensure existing patterns still work +swift test --filter GlobMatcherTests +swift test --filter ExtractIntegrationTests + +# All existing tests must pass +``` + +## Success Criteria Validation + +| Criteria | Validation Command | +|----------|-------------------| +| SC-001: Multiple nested paths | `extract --from '{A,B/C}.swift'` matches both depths | +| SC-002: Backward compatible | `swift test` — all 477+ existing tests pass | +| SC-003: <10ms expansion | Add performance test with 3 brace groups | +| SC-004: Clear errors | `extract --from '{a,}'` shows helpful error | +| SC-005: Embedded separators | `extract --from 'Sources/{A,B/C}.swift'` works | + +## Common Issues + +### Issue: Tests not found +```bash +# Ensure test file is in correct location +ls Tests/SubtreeLibTests/Utilities/BraceExpanderTests.swift +``` + +### Issue: Import errors +```bash +# BraceExpander must be public and in SubtreeLib module +# Check: public struct BraceExpander +# Check: @testable import SubtreeLib in tests +``` + +### Issue: Backward compatibility regression +```bash +# Run full test suite, not just new tests +swift test +# All 477+ tests must pass +``` diff --git a/specs/011-brace-expansion/research.md b/specs/011-brace-expansion/research.md new file mode 100644 index 0000000..86cb4e9 --- /dev/null +++ b/specs/011-brace-expansion/research.md @@ -0,0 +1,94 @@ +# Research: Brace Expansion with Embedded Path Separators + +**Feature**: 011-brace-expansion +**Date**: 2025-11-29 + +## Research Tasks + +### 1. Bash Brace Expansion Semantics + +**Task**: Understand exact bash behavior for brace expansion + +**Findings**: +- Brace expansion occurs BEFORE pathname expansion (globbing) +- `{a,b,c}` expands to separate words: `a b c` +- Multiple groups produce cartesian product: `{a,b}{1,2}` → `a1 a2 b1 b2` +- Path separators inside braces are allowed: `{a,b/c}` → `a b/c` +- No comma = no expansion: `{a}` stays as literal `{a}` +- Empty braces `{}` stay literal +- Unclosed braces stay literal: `{a,b` stays as `{a,b` +- Empty alternatives ARE valid in bash: `{a,}` → `a ` (a and empty string) + +**Decision**: Follow bash semantics EXCEPT for empty alternatives (error for safety) + +**Rationale**: Empty path components can cause unexpected filesystem behavior. Safer to error. + +### 2. Existing GlobMatcher Implementation + +**Task**: Understand why embedded path separators don't work + +**Findings**: +- GlobMatcher splits pattern by `/` into segments BEFORE processing +- `Sources/{A,B/C}.swift` becomes segments: `["Sources", "{A,B/C}.swift"]` +- Brace expansion in `matchSingleSegment` only matches if entire remaining string equals one alternative +- The alternative `B/C` in segment `{A,B/C}.swift` cannot match a path with different depth + +**Decision**: Pre-expand braces BEFORE GlobMatcher receives the pattern + +**Rationale**: Keeps GlobMatcher unchanged; expansion produces valid patterns that GlobMatcher can match. + +### 3. Swift String Parsing Patterns + +**Task**: Best practices for parsing brace expressions in Swift + +**Findings**: +- Character-by-character iteration with index tracking (used by GlobMatcher) +- Recursive descent for nested structures (not needed for flat braces) +- String.Index API for safe Unicode handling + +**Decision**: Use same pattern as GlobMatcher's `parseBraceExpansion` for consistency + +**Rationale**: Proven pattern in codebase; familiar to contributors. + +### 4. Cartesian Product Algorithm + +**Task**: Efficient cartesian product for multiple brace groups + +**Findings**: +- Iterative approach: Start with `[""]`, for each group, expand each existing pattern +- Recursive approach: Expand first group, recursively expand rest +- Memory: O(n) where n = total expanded patterns + +**Decision**: Iterative approach with early termination check + +**Rationale**: Simpler to understand; can check pattern count and warn at 100+ + +**Algorithm**: +``` +func expand(pattern) -> [String]: + groups = findBraceGroups(pattern) + if groups.isEmpty: return [pattern] + + results = [""] + for group in groups: + newResults = [] + for partial in results: + for alternative in group.alternatives: + newResults.append(partial + prefixBefore(group) + alternative) + results = newResults + if results.count > 100: warn() + return results.map { $0 + suffixAfter(lastGroup) } +``` + +## Summary + +| Topic | Decision | Rationale | +|-------|----------|-----------| +| Bash compatibility | Follow except empty alternatives | Safety over strict compatibility | +| Integration | Pre-expand before GlobMatcher | Keeps existing code unchanged | +| Parsing | Character iteration with Index | Matches existing GlobMatcher style | +| Cartesian product | Iterative with count check | Simple + allows warning at 100+ | + +## No Outstanding Unknowns + +All NEEDS CLARIFICATION items resolved. Ready for Phase 1 design. diff --git a/specs/011-brace-expansion/spec.md b/specs/011-brace-expansion/spec.md new file mode 100644 index 0000000..8e94990 --- /dev/null +++ b/specs/011-brace-expansion/spec.md @@ -0,0 +1,147 @@ +# Feature Specification: Brace Expansion with Embedded Path Separators + +**Feature Branch**: `011-brace-expansion` +**Created**: 2025-11-29 +**Status**: Complete (2025-11-30) +**Input**: User description: "Add support for embedded separators patterns such as {a,b/c} in glob patterns" + +## Overview + +Extend the existing brace expansion syntax to support **embedded path separators** inside braces. The current GlobMatcher already supports `{a,b,c}` for simple alternatives (e.g., `*.{h,c}`), but patterns like `{A,B/C}` with `/` inside braces do not work correctly because the pattern is split by `/` before brace expansion occurs. + +**Current limitation**: `Sources/{A,B/C}.swift` fails because the pattern is segmented as `["Sources", "{A,B/C}.swift"]` and the brace alternative `B/C` cannot match across path segments. + +**Solution**: Pre-expand braces BEFORE the pattern reaches GlobMatcher, so `Sources/{A,B/C}.swift` becomes two separate patterns: `Sources/A.swift` and `Sources/B/C.swift`. + +**Key Example**: +```bash +subtree extract --name swift-crypto \ + --from 'Sources/Crypto/Util/{PrettyBytes,SecureBytes,BoringSSL/RNG_boring}.swift' +``` +Expands to 3 patterns: +- `Sources/Crypto/Util/PrettyBytes.swift` +- `Sources/Crypto/Util/SecureBytes.swift` +- `Sources/Crypto/Util/BoringSSL/RNG_boring.swift` + +## Clarifications + +### Session 2025-11-29 + +- Q: How much of brace expansion is already implemented? → A: GlobMatcher already supports basic `{a,b,c}` for file extensions (e.g., `*.{h,c}`), but embedded path separators `{a,b/c}` do NOT work because patterns are split by `/` before brace expansion +- Q: Should `--to` support brace expansion? → A: No — `--to` is a destination path, not a glob pattern; brace expansion only applies to `--from` and `--exclude` patterns +- Q: What's the implementation approach? → A: Pre-expand braces at CLI level via `BraceExpander` utility BEFORE passing patterns to GlobMatcher (bash semantics) + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Basic Brace Expansion (Priority: P1) + +A developer wants to extract multiple related files from different paths using a single compact pattern instead of specifying multiple `--from` flags. + +**Why this priority**: Core value proposition — reduces verbosity and matches familiar bash syntax that developers already know. + +**Independent Test**: Can be fully tested by providing a pattern with braces and verifying it expands to the expected multiple patterns before matching. + +**Acceptance Scenarios**: + +1. **Given** a subtree with files at `Sources/Foo.swift` and `Sources/Bar.swift`, **When** the user runs `extract --from 'Sources/{Foo,Bar}.swift'`, **Then** both files are matched and extracted. + +2. **Given** a subtree with nested structure, **When** the user runs `extract --from 'Sources/{A,B/C}.swift'`, **Then** files at `Sources/A.swift` and `Sources/B/C.swift` are matched (embedded path separator supported). + +3. **Given** a pattern without braces, **When** the user runs `extract --from 'Sources/*.swift'`, **Then** the pattern is passed through unchanged to glob matching (100% backward compatible). + +--- + +### User Story 2 - Multiple Brace Groups (Priority: P2) + +A developer wants to use multiple brace groups in a single pattern to generate a cartesian product of paths, matching bash behavior exactly. + +**Why this priority**: Enables powerful pattern composition for complex directory structures without needing many separate patterns. + +**Independent Test**: Can be tested by providing a pattern with two brace groups and verifying all combinations are generated. + +**Acceptance Scenarios**: + +1. **Given** a subtree with files in multiple directories, **When** the user runs `extract --from '{Sources,Tests}/{Foo,Bar}.swift'`, **Then** 4 patterns are generated: `Sources/Foo.swift`, `Sources/Bar.swift`, `Tests/Foo.swift`, `Tests/Bar.swift`. + +2. **Given** a pattern with 3 brace groups, **When** the pattern is expanded, **Then** all combinations are generated (cartesian product of all groups). + +--- + +### User Story 3 - Pass-Through for Invalid Patterns (Priority: P3) + +A developer accidentally uses malformed brace syntax. The system should handle this gracefully following bash semantics. + +**Why this priority**: Robustness and user-friendliness — malformed patterns shouldn't cause crashes or confusing errors when bash would treat them as literals. + +**Independent Test**: Can be tested by providing various malformed patterns and verifying correct handling. + +**Acceptance Scenarios**: + +1. **Given** an unclosed brace pattern like `{a,b`, **When** expansion is attempted, **Then** the pattern is treated as literal text (no expansion, passed through unchanged). + +2. **Given** a single-alternative pattern like `{a}`, **When** expansion is attempted, **Then** the pattern is treated as literal text (no comma = no expansion). + +3. **Given** empty braces `{}`, **When** expansion is attempted, **Then** the pattern is treated as literal text. + +--- + +### User Story 4 - Error on Empty Alternatives (Priority: P3) + +A developer uses a pattern with empty alternatives like `{a,}` or `{,b}`. The system should reject this with a clear error to prevent accidental empty path components. + +**Why this priority**: Safety — empty path components can cause unexpected behavior. This is a deliberate deviation from bash for safety. + +**Independent Test**: Can be tested by providing patterns with empty alternatives and verifying error response. + +**Acceptance Scenarios**: + +1. **Given** a pattern with trailing empty alternative `{a,}`, **When** expansion is attempted, **Then** an error is returned with a clear message explaining empty alternatives are not supported. + +2. **Given** a pattern with leading empty alternative `{,b}`, **When** expansion is attempted, **Then** an error is returned. + +3. **Given** a pattern with middle empty alternative `{a,,b}`, **When** expansion is attempted, **Then** an error is returned. + +--- + +### Edge Cases + +- **Nested braces**: `{a,{b,c}}` — Not supported in MVP (treated as literal, no expansion). Deferred to backlog. +- **Literal braces in filenames**: Users needing literal `{` or `}` can use character class workaround `[{]` or `[}]`. Backslash escaping deferred to backlog. +- **Braces inside glob patterns**: `*.{swift,h}` should expand to `*.swift` and `*.h` (braces processed before glob matching). +- **Very long expansions**: Large cartesian products (e.g., 1000+ patterns) should be handled but may warrant a warning. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST expand brace patterns `{a,b,c}` into multiple patterns before glob matching +- **FR-002**: System MUST support embedded path separators inside braces (e.g., `{a,b/c}` expands to `a` and `b/c`) +- **FR-003**: System MUST support multiple brace groups in a single pattern with cartesian product expansion +- **FR-004**: System MUST apply brace expansion to `--from` and `--exclude` patterns +- **FR-005**: System MUST treat unclosed braces as literal text (no expansion) +- **FR-006**: System MUST treat single-alternative braces `{a}` as literal text (no expansion) +- **FR-007**: System MUST treat empty braces `{}` as literal text (no expansion) +- **FR-008**: System MUST return an error for patterns with empty alternatives (`{a,}`, `{,b}`, `{a,,b}`) +- **FR-009**: System MUST maintain 100% backward compatibility with existing patterns (no braces = no change) +- **FR-010**: System MUST expand braces before passing patterns to existing glob matching logic + +### Non-Functional Requirements + +- **NFR-001**: Brace expansion MUST complete in <10ms for typical patterns (≤10 alternatives, ≤3 brace groups) +- **NFR-002**: System SHOULD warn if expansion generates more than 100 patterns + +### Out of Scope (Deferred to Backlog) + +- Nested brace expansion `{a,{b,c}}` +- Backslash escaping for literal braces `\{`, `\}` +- Numeric ranges `{1..10}` + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Users can extract files from multiple nested paths (with different directory depths) using a single `--from` pattern +- **SC-002**: All existing extract operations work unchanged (100% backward compatibility) +- **SC-003**: Expansion of patterns with ≤3 brace groups completes in <10ms +- **SC-004**: Error messages for invalid patterns clearly explain the issue and suggest corrections +- **SC-005**: Patterns with embedded path separators like `Sources/{A,B/C}.swift` correctly match files at different directory depths diff --git a/specs/011-brace-expansion/tasks.md b/specs/011-brace-expansion/tasks.md new file mode 100644 index 0000000..1a798a7 --- /dev/null +++ b/specs/011-brace-expansion/tasks.md @@ -0,0 +1,277 @@ +# Tasks: Brace Expansion with Embedded Path Separators + +**Input**: Design documents from `/specs/011-brace-expansion/` +**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/ ✅ + +**Tests**: TDD approach — tests written first, verified to fail, then implementation + +**Organization**: Tasks grouped by user story for independent implementation and testing + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1, US2, US3, US4) +- Exact file paths included in descriptions + +## Path Conventions + +``` +Sources/SubtreeLib/Utilities/BraceExpander.swift # NEW: Main implementation +Tests/SubtreeLibTests/Utilities/BraceExpanderTests.swift # NEW: Unit tests +Sources/SubtreeLib/Commands/ExtractCommand.swift # MODIFY: Integration +Tests/IntegrationTests/ExtractIntegrationTests.swift # MODIFY: Integration tests +``` + +--- + +## Phase 1: Setup + +**Purpose**: Create file structure and error type + +- [x] T001 Create `BraceExpanderTests.swift` test file skeleton in `Tests/SubtreeLibTests/Utilities/` +- [x] T002 Create `BraceExpander.swift` source file with `BraceExpanderError` enum in `Sources/SubtreeLib/Utilities/` +- [x] T003 Verify project builds with empty implementations: `swift build` + +--- + +## Phase 2: Foundational + +**Purpose**: Core parsing infrastructure that ALL user stories depend on + +**⚠️ CRITICAL**: User story implementation cannot begin until brace group detection works + +### Tests (TDD) + +- [x] T004 [P] Write test for finding brace groups in pattern in `Tests/SubtreeLibTests/Utilities/BraceExpanderTests.swift` +- [x] T005 Verify T004 tests FAIL (no implementation yet): `swift test --filter BraceExpanderTests` + +### Implementation + +- [x] T006 Implement `findBraceGroups()` private method to locate `{...}` with commas in `Sources/SubtreeLib/Utilities/BraceExpander.swift` +- [x] T007 Verify T004 tests PASS: `swift test --filter BraceExpanderTests` + +**Checkpoint**: Brace group parsing works — user story implementation can begin + +--- + +## Phase 3: User Story 1 - Basic Brace Expansion (Priority: P1) 🎯 MVP + +**Goal**: Expand `{a,b}` and `{a,b/c}` patterns into multiple strings + +**Independent Test**: `BraceExpander.expand("{a,b}")` returns `["a", "b"]` + +### Tests (TDD) + +- [x] T008 [P] [US1] Write tests for basic expansion (`{a,b}` → `["a", "b"]`) in `Tests/SubtreeLibTests/Utilities/BraceExpanderTests.swift` +- [x] T009 [P] [US1] Write tests for embedded path separators (`{a,b/c}` → `["a", "b/c"]`) in `Tests/SubtreeLibTests/Utilities/BraceExpanderTests.swift` +- [x] T010 [P] [US1] Write tests for patterns with prefix/suffix (`*.{h,c}` → `["*.h", "*.c"]`) in `Tests/SubtreeLibTests/Utilities/BraceExpanderTests.swift` +- [x] T011 [US1] Verify T008-T010 tests FAIL: `swift test --filter BraceExpanderTests` + +### Implementation + +- [x] T012 [US1] Implement `expand(_ pattern: String) throws -> [String]` for single brace group in `Sources/SubtreeLib/Utilities/BraceExpander.swift` +- [x] T013 [US1] Verify T008-T010 tests PASS: `swift test --filter BraceExpanderTests` + +**Checkpoint**: Basic brace expansion works — `{a,b}` and `{a,b/c}` expand correctly + +--- + +## Phase 4: User Story 2 - Multiple Brace Groups (Priority: P2) + +**Goal**: Expand `{a,b}{1,2}` with cartesian product → `["a1", "a2", "b1", "b2"]` + +**Independent Test**: `BraceExpander.expand("{a,b}{1,2}")` returns 4 patterns + +### Tests (TDD) + +- [x] T014 [P] [US2] Write tests for two brace groups cartesian product in `Tests/SubtreeLibTests/Utilities/BraceExpanderTests.swift` +- [x] T015 [P] [US2] Write tests for three brace groups (8 patterns) in `Tests/SubtreeLibTests/Utilities/BraceExpanderTests.swift` +- [x] T016 [US2] Verify T014-T015 tests FAIL: `swift test --filter BraceExpanderTests` + +### Implementation + +- [x] T017 [US2] Extend `expand()` to handle multiple brace groups with iterative cartesian product in `Sources/SubtreeLib/Utilities/BraceExpander.swift` +- [x] T018 [US2] Add warning to stderr when expansion exceeds 100 patterns in `Sources/SubtreeLib/Utilities/BraceExpander.swift` +- [x] T019 [US2] Verify T014-T015 tests PASS: `swift test --filter BraceExpanderTests` + +**Checkpoint**: Multiple brace groups expand as cartesian product + +--- + +## Phase 5: User Story 3 - Pass-Through for Invalid Patterns (Priority: P3) + +**Goal**: Treat malformed braces as literal text (bash behavior) + +**Independent Test**: `BraceExpander.expand("{a}")` returns `["{a}"]` (no expansion) + +### Tests (TDD) + +- [x] T020 [P] [US3] Write tests for no-comma pass-through (`{a}` → `["{a}"]`) in `Tests/SubtreeLibTests/Utilities/BraceExpanderTests.swift` +- [x] T021 [P] [US3] Write tests for empty braces pass-through (`{}` → `["{}"]`) in `Tests/SubtreeLibTests/Utilities/BraceExpanderTests.swift` +- [x] T022 [P] [US3] Write tests for unclosed braces pass-through (`{a,b` → `["{a,b"]`) in `Tests/SubtreeLibTests/Utilities/BraceExpanderTests.swift` +- [x] T023 [P] [US3] Write tests for no-braces pass-through (`plain.txt` → `["plain.txt"]`) in `Tests/SubtreeLibTests/Utilities/BraceExpanderTests.swift` +- [x] T024 [US3] Verify T020-T023 tests FAIL: `swift test --filter BraceExpanderTests` + +### Implementation + +- [x] T025 [US3] Update `findBraceGroups()` to skip invalid patterns in `Sources/SubtreeLib/Utilities/BraceExpander.swift` +- [x] T026 [US3] Verify T020-T023 tests PASS: `swift test --filter BraceExpanderTests` + +**Checkpoint**: Invalid patterns pass through unchanged + +--- + +## Phase 6: User Story 4 - Error on Empty Alternatives (Priority: P3) + +**Goal**: Throw error for `{a,}`, `{,b}`, `{a,,b}` patterns + +**Independent Test**: `BraceExpander.expand("{a,}")` throws `BraceExpanderError.emptyAlternative` + +### Tests (TDD) + +- [x] T027 [P] [US4] Write tests for trailing empty alternative error (`{a,}`) in `Tests/SubtreeLibTests/Utilities/BraceExpanderTests.swift` +- [x] T028 [P] [US4] Write tests for leading empty alternative error (`{,b}`) in `Tests/SubtreeLibTests/Utilities/BraceExpanderTests.swift` +- [x] T029 [P] [US4] Write tests for middle empty alternative error (`{a,,b}`) in `Tests/SubtreeLibTests/Utilities/BraceExpanderTests.swift` +- [x] T030 [US4] Verify T027-T029 tests FAIL: `swift test --filter BraceExpanderTests` + +### Implementation + +- [x] T031 [US4] Add empty alternative detection in brace parsing in `Sources/SubtreeLib/Utilities/BraceExpander.swift` +- [x] T032 [US4] Throw `BraceExpanderError.emptyAlternative(pattern)` with full pattern in `Sources/SubtreeLib/Utilities/BraceExpander.swift` +- [x] T033 [US4] Verify T027-T029 tests PASS: `swift test --filter BraceExpanderTests` + +**Checkpoint**: Empty alternatives are rejected with clear error + +--- + +## Phase 7: Integration + +**Purpose**: Wire BraceExpander into ExtractCommand + +### Tests (TDD) + +- [x] T034 [P] Write integration test for extract with embedded path separator pattern in `Tests/IntegrationTests/ExtractIntegrationTests.swift` +- [x] T035 [P] Write integration test for extract with multiple brace groups in `Tests/IntegrationTests/ExtractIntegrationTests.swift` +- [x] T036 [P] Write integration test for extract error on empty alternative in `Tests/IntegrationTests/ExtractIntegrationTests.swift` +- [x] T037 Verify T034-T036 tests FAIL: `swift test --filter ExtractIntegrationTests` + +### Implementation + +- [x] T038 Add pattern expansion helper function in `Sources/SubtreeLib/Commands/ExtractCommand.swift` +- [x] T039 Expand `--from` patterns before file matching in `Sources/SubtreeLib/Commands/ExtractCommand.swift` +- [x] T040 Expand `--exclude` patterns before file matching in `Sources/SubtreeLib/Commands/ExtractCommand.swift` +- [x] T041 Handle `BraceExpanderError` with user-friendly error message in `Sources/SubtreeLib/Commands/ExtractCommand.swift` +- [x] T042 Verify T034-T036 tests PASS: `swift test --filter ExtractIntegrationTests` + +**Checkpoint**: Brace expansion works end-to-end in extract command + +--- + +## Phase 8: Polish & Validation + +**Purpose**: Final verification and backward compatibility + +- [x] T043 [P] Run full test suite and verify all 477+ existing tests pass: `swift test` +- [x] T044 Run quickstart.md validation commands +- [x] T045 Update README.md with brace expansion examples in glob pattern documentation +- [x] T046 [P] Add performance test verifying expansion completes in <10ms for pattern with 3 brace groups in `Tests/SubtreeLibTests/Utilities/BraceExpanderTests.swift` +- [x] T047 Final code review for KISS/DRY compliance + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +``` +Phase 1 (Setup) → Phase 2 (Foundational) → User Stories (3-6) → Integration (7) → Polish (8) +``` + +### User Story Dependencies + +| Story | Depends On | Can Start After | +|-------|------------|-----------------| +| US1 (Basic) | Phase 2 | T007 complete | +| US2 (Multiple Groups) | US1 | T013 complete | +| US3 (Pass-Through) | Phase 2 | T007 complete (parallel with US1) | +| US4 (Empty Error) | Phase 2 | T007 complete (parallel with US1) | + +### Within Each User Story (TDD Cycle) + +1. Write tests → 2. Verify FAIL → 3. Implement → 4. Verify PASS + +### Parallel Opportunities + +**Phase 1**: T001-T002 can run in parallel +**Phase 3 Tests**: T008-T010 can run in parallel +**Phase 5 Tests**: T020-T023 can run in parallel +**Phase 6 Tests**: T027-T029 can run in parallel +**Phase 7 Tests**: T034-T036 can run in parallel +**Phase 8**: T043-T044 can run in parallel + +--- + +## Parallel Example: User Story 1 + +```bash +# Write all US1 tests in parallel: +T008: Test basic expansion {a,b} +T009: Test embedded separators {a,b/c} +T010: Test prefix/suffix *.{h,c} + +# Then verify fail, implement, verify pass (sequential) +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (brace group detection) +3. Complete Phase 3: User Story 1 (basic expansion) +4. **STOP and VALIDATE**: Test `BraceExpander.expand("{a,b/c}")` works +5. Can demo/integrate at this point + +### Incremental Delivery + +1. Setup + Foundational → Framework ready +2. US1 → Basic expansion works (MVP!) +3. US2 → Cartesian product works +4. US3 + US4 → Edge cases handled +5. Integration → End-to-end works +6. Polish → Production ready + +### Suggested MVP Scope + +**Minimum viable**: Phases 1-3 (Setup + Foundational + US1) +**Delivers**: Basic `{a,b/c}` expansion working in unit tests +**Estimate**: ~15 tasks + +--- + +## Summary + +| Metric | Count | +|--------|-------| +| **Total Tasks** | 47 | +| **Phase 1 (Setup)** | 3 | +| **Phase 2 (Foundational)** | 4 | +| **Phase 3 (US1 - Basic)** | 6 | +| **Phase 4 (US2 - Multiple)** | 6 | +| **Phase 5 (US3 - Pass-Through)** | 7 | +| **Phase 6 (US4 - Errors)** | 7 | +| **Phase 7 (Integration)** | 9 | +| **Phase 8 (Polish)** | 5 | +| **Parallel Opportunities** | 18 tasks marked [P] | + +--- + +## Notes + +- All tests follow TDD: write → fail → implement → pass +- [P] tasks can run in parallel (different files, no dependencies) +- Commit after each TDD cycle (test pass) +- Stop at any checkpoint to validate independently +- Existing 477 tests MUST continue passing (backward compatibility)