mirror of
https://github.com/instructkr/claw-code.git
synced 2026-10-30 00:00:22 +08:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
122cf3ab0c | ||
|
|
eb1537ca92 | ||
|
|
0508c2aa05 | ||
|
|
cf18dac3ee | ||
|
|
2b64546b30 | ||
|
|
30f607e03a | ||
|
|
a2eb74c00e | ||
|
|
44ff1249fa | ||
|
|
f4c375641a | ||
|
|
46eba21620 | ||
|
|
e8c14616c6 | ||
|
|
16541e987b | ||
|
|
baa31c7515 | ||
|
|
6b8bdb2ce7 | ||
|
|
1e55fb11bc | ||
|
|
7e6981fbe7 |
No files matched your search
@@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.1.4] - 2026-10-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- GJC-compatible line range selectors for read_file tool (`:50-200`, `:50+150` syntax)
|
||||||
|
- GJC-compatible `:conflicts` selector for git merge conflict detection
|
||||||
|
- SDK wire transport with WebSocket support
|
||||||
|
- Nix flake development environment
|
||||||
|
- `/status`, `/cost`, and `/version` slash commands
|
||||||
|
- Persistent provider configuration loading
|
||||||
|
- Pagination support for large read_file results
|
||||||
|
- Comprehensive GJC compatibility documentation and analysis
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Windows path containment checks for security boundary enforcement
|
||||||
|
- Git bash detection on Windows with proper probe caching
|
||||||
|
- Unresolvable path denial to prevent escape attempts
|
||||||
|
- Auto-compaction context window measurement
|
||||||
|
- Tool result preservation on failed turns
|
||||||
|
- Model stall recovery without tool calls
|
||||||
|
- Streaming markdown formatting with preserved block separators
|
||||||
|
- Local model validation bypass generalization beyond Ollama
|
||||||
|
- Release artifact checksums on macOS and Linux
|
||||||
|
- Duplicate assistant text rendering
|
||||||
|
- Workspace slash command wire-up
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Enhanced path scope validation for multiple Windows path spellings
|
||||||
|
- Improved error handling and configuration defaults
|
||||||
|
- Upgraded GitHub Actions to immutable commit SHAs for CI security
|
||||||
|
|
||||||
|
### Known Limitations
|
||||||
|
|
||||||
|
- ACP / Zed integration is not yet implemented (status stub available via `claw acp`)
|
||||||
|
- Python porting workspace is for reference only, not used in production
|
||||||
|
|
||||||
## [0.1.3] - 2026-10-13
|
## [0.1.3] - 2026-10-13
|
||||||
|
|
||||||
This is the first public binary release of Claw Code.
|
This is the first public binary release of Claw Code.
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
# GJC Tool Semantics Alignment: Phase 1 (read/edit/search/find)
|
||||||
|
|
||||||
|
**Status**: Implementation plan
|
||||||
|
**Issue**: #3323
|
||||||
|
**Scope**: Slice 2.1 - Tool catalog semantic parity for read/edit/search/find
|
||||||
|
**Target**: Ship in batches with focused PRs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. EXECUTIVE SUMMARY
|
||||||
|
|
||||||
|
This document details the semantic gaps between claw-code's current tool implementations and GJC's (gajae-code) reference implementations for the highest-value tools: **read**, **edit**, **search**, and **find**.
|
||||||
|
|
||||||
|
### Key Gaps
|
||||||
|
|
||||||
|
| Tool | Current (claw-code) | Target (GJC) | Gap | Complexity |
|
||||||
|
|------|-----|--------|-----|------------|
|
||||||
|
| **read** | offset/limit pagination | Line selectors (`:L-L+N`), raw mode, truncation footers | High | Medium |
|
||||||
|
| **write** | Simple file replacement | Full replacement semantics (matching GJC) | Low | Low |
|
||||||
|
| **edit** | String replacement (old→new) | String replacement with rich patch output | Low | Low |
|
||||||
|
| **find** (glob) | Pattern expansion only | Recursive glob with filtering | Low | Low |
|
||||||
|
| **search** (grep) | Regex search with context | Regex with context + advanced filtering | Low | Low |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. GJC TOOL SEMANTICS REFERENCE
|
||||||
|
|
||||||
|
### 2.1 READ Tool
|
||||||
|
|
||||||
|
**Source**: `gajae-code/packages/coding-agent/src/tools/read.ts` (3,500+ LOC)
|
||||||
|
|
||||||
|
#### Input Schema
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
path: string; // File path (relative or absolute)
|
||||||
|
offset?: number; // Line offset (for pagination)
|
||||||
|
limit?: number; // Lines to read (for pagination)
|
||||||
|
// GJC extensions (missing in claw-code):
|
||||||
|
selector?: string; // Line range syntax:
|
||||||
|
// ":50" = line 50 onward
|
||||||
|
// ":50-200" = lines 50-200 inclusive
|
||||||
|
// ":50+150" = 150 lines starting at line 50
|
||||||
|
// ":raw" = full file verbatim
|
||||||
|
// ":conflicts" = git merge conflicts only
|
||||||
|
truncationDirection?: "head" | "tail" | "both"; // Which end to keep
|
||||||
|
maxSummaryBytes?: number; // Summary budget (default 50KB)
|
||||||
|
maxSummaryLines?: number; // Summary budget (default 20K lines)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Output Structure
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file": {
|
||||||
|
"path": "src/main.rs",
|
||||||
|
"content": "...",
|
||||||
|
"totalLines": 500,
|
||||||
|
"totalBytes": 12345,
|
||||||
|
"truncatedBy": "lines" | "bytes" | "middle" | null,
|
||||||
|
"displayMode": "summarized" | "raw" | "receipt"
|
||||||
|
},
|
||||||
|
"truncationFooter": "[Showing lines 1-50 of 500; re-read ...]"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Key Behaviors
|
||||||
|
|
||||||
|
1. **Line Range Selectors** (`:selector` syntax)
|
||||||
|
- `:50` = start at line 50
|
||||||
|
- `:50-200` = lines 50-200 inclusive
|
||||||
|
- `:50+150` = 150 lines starting at 50
|
||||||
|
- `:50+1` = exactly one line
|
||||||
|
- `:5-16,960-973` = multiple ranges
|
||||||
|
- `:raw` = full verbatim
|
||||||
|
- `:conflicts` = git merge conflicts
|
||||||
|
|
||||||
|
2. **Truncation Modes**
|
||||||
|
- Default: 50KB bytes limit, 20K lines limit
|
||||||
|
- Direction: `head` (keep start), `tail` (keep end), `both` (keep both ends)
|
||||||
|
- Footer includes recovery hint (e.g., "`re-read path:1-9999` for verbatim")
|
||||||
|
|
||||||
|
3. **Structural Summarization**
|
||||||
|
- Code: collapse function bodies to `..` or `…` (merged brace pairs)
|
||||||
|
- Prose: preserve full lines, truncate at word boundaries
|
||||||
|
- Special files: documents (PDF, DOCX, XLSX) converted via markit
|
||||||
|
|
||||||
|
4. **Archive Support**
|
||||||
|
- `.tar`, `.tar.gz`, `.tgz`, `.zip`: member read with `archive.ext:path/inside`
|
||||||
|
- SQLite: `.sqlite:table` or `.sqlite:table:key` queries
|
||||||
|
- Line ranges apply to archive members
|
||||||
|
|
||||||
|
5. **Output Formatting**
|
||||||
|
- Anchored output: Line prefixes with source line numbers and hash anchors
|
||||||
|
- Optional hash lines: Marks structural boundaries (`# pragma`, function start, etc.)
|
||||||
|
- Prose column cap: Long lines wrapped/truncated at column limit
|
||||||
|
|
||||||
|
### 2.2 EDIT Tool
|
||||||
|
|
||||||
|
**Source**: `gajae-code/packages/coding-agent/src/tools/edit.ts`
|
||||||
|
|
||||||
|
#### Input Schema
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
path: string; // File to edit
|
||||||
|
old_text: string; // Text to replace (required for edit)
|
||||||
|
new_text: string; // Replacement text
|
||||||
|
all?: boolean; // Replace all occurrences (default: first only)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Output Structure
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file": {
|
||||||
|
"path": "src/main.rs",
|
||||||
|
"editCount": 1,
|
||||||
|
"beforeSize": 12345,
|
||||||
|
"afterSize": 12400
|
||||||
|
},
|
||||||
|
"patch": [
|
||||||
|
{
|
||||||
|
"type": "replace",
|
||||||
|
"lineStart": 50,
|
||||||
|
"lineEnd": 55,
|
||||||
|
"before": "old content",
|
||||||
|
"after": "new content",
|
||||||
|
"context": {"before": 3, "after": 3}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Key Behaviors
|
||||||
|
|
||||||
|
1. **Exact Match Required**: `old_text` must match exactly (no fuzzy matching)
|
||||||
|
2. **Smallest Replacement**: Use smallest unique old_text that identifies the change
|
||||||
|
3. **All-or-Nothing**: Either all occurrences replaced or none (no partial)
|
||||||
|
4. **Patch Metadata**: Line numbers, context, before/after preserved
|
||||||
|
|
||||||
|
### 2.3 SEARCH Tool
|
||||||
|
|
||||||
|
**Source**: `gajae-code/packages/coding-agent/src/tools/search.ts`
|
||||||
|
|
||||||
|
#### Input Schema
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
pattern: string; // Regex pattern
|
||||||
|
paths?: string[]; // Files/dirs to search
|
||||||
|
context?: number; // Context lines before/after (default: 2)
|
||||||
|
i?: boolean; // Case-insensitive
|
||||||
|
glob_exclude?: string[]; // Patterns to skip
|
||||||
|
file_type?: string; // Filter by extension
|
||||||
|
limit?: number; // Max results
|
||||||
|
truncation?: "head" | "tail" | "both";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Output Structure
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
{
|
||||||
|
"file": "src/main.rs",
|
||||||
|
"lineNumber": 42,
|
||||||
|
"line": "fn process() {",
|
||||||
|
"before": ["// Process data", ""],
|
||||||
|
"after": [" // Implementation", ""]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Found 5 matches in 3 files"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 FIND Tool
|
||||||
|
|
||||||
|
**Source**: `gajae-code/packages/coding-agent/src/tools/find.ts`
|
||||||
|
|
||||||
|
#### Input Schema
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
paths: string[]; // Glob patterns
|
||||||
|
timeout?: number; // Timeout in seconds (default: 5)
|
||||||
|
gitignore?: boolean; // Respect .gitignore (default: true)
|
||||||
|
hidden?: boolean; // Include hidden files (default: true)
|
||||||
|
limit?: number; // Max results (default: 1000)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Output Structure
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "src/main.rs",
|
||||||
|
"modified": "2026-10-13T12:34:56Z",
|
||||||
|
"size": 12345
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Found 42 files matching pattern",
|
||||||
|
"truncated": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. CURRENT CLAW-CODE IMPLEMENTATION GAPS
|
||||||
|
|
||||||
|
### 3.1 READ Tool Gaps
|
||||||
|
|
||||||
|
**File**: `rust/crates/runtime/src/file_ops.rs:713`
|
||||||
|
|
||||||
|
**Current**:
|
||||||
|
```rust
|
||||||
|
pub fn read_file_in_workspace(
|
||||||
|
path: &str,
|
||||||
|
offset: Option<usize>, // Line offset only
|
||||||
|
limit: Option<usize>, // Lines only
|
||||||
|
workspace_root: &Path,
|
||||||
|
) -> io::Result<ReadFileOutput>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Missing**:
|
||||||
|
- ✗ Line range selectors (`:L-L+N` syntax)
|
||||||
|
- ✗ Raw mode (`:raw`)
|
||||||
|
- ✓ Git merge conflict extraction (`:conflicts`)
|
||||||
|
- ✗ Truncation footers with recovery hints
|
||||||
|
- ✗ Structural summarization (collapse braces)
|
||||||
|
- ✗ Archive/SQLite support
|
||||||
|
- ✗ Multi-range selectors (`:5-16,960-973`)
|
||||||
|
- ✗ Prose column cap
|
||||||
|
- ✗ Hash line anchors
|
||||||
|
|
||||||
|
**Estimated LOC to Add**: 1,200-1,500 (including tests)
|
||||||
|
|
||||||
|
### 3.2 EDIT Tool Gaps
|
||||||
|
|
||||||
|
**File**: `rust/crates/runtime/src/file_ops.rs:760`
|
||||||
|
|
||||||
|
**Current**: Basic string replacement works
|
||||||
|
**Missing**:
|
||||||
|
- ✗ Rich patch metadata (line numbers, context lines)
|
||||||
|
|
||||||
|
**Estimated LOC to Add**: 200-300
|
||||||
|
|
||||||
|
### 3.3 SEARCH Tool Gaps
|
||||||
|
|
||||||
|
**File**: `rust/crates/tools/src/lib.rs` (grep search)
|
||||||
|
|
||||||
|
**Current**: Basic regex with context
|
||||||
|
**Missing**:
|
||||||
|
- ✗ Advanced filtering (file type, glob exclude)
|
||||||
|
- ✗ Structured output format
|
||||||
|
|
||||||
|
**Estimated LOC to Add**: 300-400
|
||||||
|
|
||||||
|
### 3.4 FIND Tool Gaps
|
||||||
|
|
||||||
|
**File**: `rust/crates/tools/src/lib.rs` (glob search)
|
||||||
|
|
||||||
|
**Current**: Basic glob expansion
|
||||||
|
**Missing**:
|
||||||
|
- ✗ Recursive globs
|
||||||
|
- ✗ Gitignore filtering
|
||||||
|
- ✗ Hidden file control
|
||||||
|
- ✗ File metadata
|
||||||
|
|
||||||
|
**Estimated LOC to Add**: 200-300
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. IMPLEMENTATION PLAN (BATCHED)
|
||||||
|
|
||||||
|
### Batch 1: READ Tool (Priority P0)
|
||||||
|
- Add line range selector parsing
|
||||||
|
- Implement `:raw` mode
|
||||||
|
- ✓ Implement `:conflicts` mode
|
||||||
|
- Add truncation footers with recovery hints
|
||||||
|
- Implement structural summarization
|
||||||
|
- Add tests for all selector combinations
|
||||||
|
|
||||||
|
**Files to Modify**:
|
||||||
|
- `rust/crates/runtime/src/file_ops.rs` (read_file_in_workspace)
|
||||||
|
- `rust/crates/tools/src/lib.rs` (ReadFileInput struct)
|
||||||
|
- Tests in both
|
||||||
|
|
||||||
|
**Size Estimate**: 1,200 LOC
|
||||||
|
**Complexity**: Medium (string parsing, output formatting)
|
||||||
|
|
||||||
|
### Batch 2: EDIT Tool (Priority P1)
|
||||||
|
- Add patch metadata to output
|
||||||
|
- Ensure exact match semantics
|
||||||
|
- Add comprehensive tests
|
||||||
|
|
||||||
|
**Files to Modify**:
|
||||||
|
- `rust/crates/runtime/src/file_ops.rs` (edit_file_in_workspace)
|
||||||
|
- Tests
|
||||||
|
|
||||||
|
**Size Estimate**: 200 LOC
|
||||||
|
**Complexity**: Low
|
||||||
|
|
||||||
|
### Batch 3: SEARCH/FIND Tools (Priority P1)
|
||||||
|
- Enhance grep/find with GJC-compatible filtering
|
||||||
|
- Add structured output format
|
||||||
|
- Add metadata (file type, modified time)
|
||||||
|
|
||||||
|
**Files to Modify**:
|
||||||
|
- `rust/crates/tools/src/lib.rs` (glob_search, grep_search)
|
||||||
|
- `rust/crates/runtime/src/file_ops.rs`
|
||||||
|
|
||||||
|
**Size Estimate**: 500 LOC
|
||||||
|
**Complexity**: Low-Medium
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. VERIFICATION STRATEGY
|
||||||
|
|
||||||
|
Each batch will include:
|
||||||
|
1. **Semantic Tests**: Verify tool inputs/outputs match GJC reference
|
||||||
|
2. **Edge Case Tests**: Line ranges, truncation, archives, etc.
|
||||||
|
3. **Integration Tests**: End-to-end with claw CLI
|
||||||
|
4. **Parity Shadow Test**: Run same inputs against both GJC and claw-code, compare outputs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. ROLLOUT SCHEDULE
|
||||||
|
|
||||||
|
- **Batch 1 (READ)**: Week 1 - Enable most impactful tool first
|
||||||
|
- **Batch 2 (EDIT)**: Week 1-2 - Quick follow-up
|
||||||
|
- **Batch 3 (SEARCH/FIND)**: Week 2 - Lower priority but valuable for discoverability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. REFERENCES
|
||||||
|
|
||||||
|
- GJC Read Tool: `/home/bellman/Workspace/gajae-code/packages/coding-agent/src/tools/read.ts`
|
||||||
|
- claw-code file_ops: `rust/crates/runtime/src/file_ops.rs`
|
||||||
|
- claw-code tools: `rust/crates/tools/src/lib.rs`
|
||||||
|
- Gap Matrix: `docs/gjc-compat/gap-matrix.md`
|
||||||
+1
-1
@@ -41,7 +41,7 @@ TUI rule: formatting fns take `&mut impl Write`, never stdout directly. Never mi
|
|||||||
|
|
||||||
Library crates don't carry the `claw-` prefix. Binary crates do (except legacy `rusty-claude-cli`).
|
Library crates don't carry the `claw-` prefix. Binary crates do (except legacy `rusty-claude-cli`).
|
||||||
|
|
||||||
Workspace version is `0.1.3`, `publish = false`, MIT license.
|
Workspace version is `0.1.4`, `publish = false`, MIT license.
|
||||||
|
|
||||||
No rust-toolchain file, no MSRV. CI pins `dtolnay/rust-toolchain@stable`.
|
No rust-toolchain file, no MSRV. CI pins `dtolnay/rust-toolchain@stable`.
|
||||||
|
|
||||||
|
|||||||
Generated
+12
-12
@@ -93,7 +93,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "api"
|
name = "api"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"criterion",
|
"criterion",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -478,7 +478,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "claw-analog"
|
name = "claw-analog"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"api",
|
"api",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -498,7 +498,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "claw-rag-service"
|
name = "claw-rag-service"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum 0.8.9",
|
"axum 0.8.9",
|
||||||
"blake3",
|
"blake3",
|
||||||
@@ -531,7 +531,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "commands"
|
name = "commands"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"plugins",
|
"plugins",
|
||||||
"runtime",
|
"runtime",
|
||||||
@@ -540,7 +540,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compat-harness"
|
name = "compat-harness"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"commands",
|
"commands",
|
||||||
"runtime",
|
"runtime",
|
||||||
@@ -1568,7 +1568,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mock-anthropic-service"
|
name = "mock-anthropic-service"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"api",
|
"api",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1761,7 +1761,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "plugins"
|
name = "plugins"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2136,7 +2136,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "runtime"
|
name = "runtime"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"glob",
|
"glob",
|
||||||
"plugins",
|
"plugins",
|
||||||
@@ -2261,7 +2261,7 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rusty-claude-cli"
|
name = "rusty-claude-cli"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"api",
|
"api",
|
||||||
"commands",
|
"commands",
|
||||||
@@ -2333,7 +2333,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sdk"
|
name = "sdk"
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2619,7 +2619,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "telemetry"
|
name = "telemetry"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2906,7 +2906,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tools"
|
name = "tools"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"api",
|
"api",
|
||||||
"aspect-core",
|
"aspect-core",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
publish = false
|
publish = false
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ async fn send_message_posts_json_and_parses_response() {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
request.headers.get("user-agent").map(String::as_str),
|
request.headers.get("user-agent").map(String::as_str),
|
||||||
Some("claude-code/0.1.3")
|
Some("claude-code/0.1.4")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
request.headers.get("anthropic-beta").map(String::as_str),
|
request.headers.get("anthropic-beta").map(String::as_str),
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ use regex::RegexBuilder;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use walkdir::{DirEntry, WalkDir};
|
use walkdir::{DirEntry, WalkDir};
|
||||||
|
|
||||||
|
use crate::line_range_selector::{self, LineRangeSelector};
|
||||||
|
|
||||||
/// Maximum file size that can be read (10 MB).
|
/// Maximum file size that can be read (10 MB).
|
||||||
const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
|
const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -88,6 +90,9 @@ pub struct ReadFileOutput {
|
|||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub file: TextFilePayload,
|
pub file: TextFilePayload,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "truncationFooter")]
|
||||||
|
pub truncation_footer: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Structured patch hunk emitted by write and edit operations.
|
/// Structured patch hunk emitted by write and edit operations.
|
||||||
@@ -201,6 +206,19 @@ pub fn read_file(
|
|||||||
path: &str,
|
path: &str,
|
||||||
offset: Option<usize>,
|
offset: Option<usize>,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
|
) -> io::Result<ReadFileOutput> {
|
||||||
|
read_file_with_selector(path, offset, limit, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a text file with optional GJC-style line range selector.
|
||||||
|
///
|
||||||
|
/// The selector takes precedence over offset/limit if provided.
|
||||||
|
/// Selector syntax: `:50`, `:50-200`, `:50+150`, `:raw`, `:conflicts`, `:5-16,960-973`
|
||||||
|
pub fn read_file_with_selector(
|
||||||
|
path: &str,
|
||||||
|
offset: Option<usize>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
selector: Option<&str>,
|
||||||
) -> io::Result<ReadFileOutput> {
|
) -> io::Result<ReadFileOutput> {
|
||||||
let absolute_path = normalize_path(path)?;
|
let absolute_path = normalize_path(path)?;
|
||||||
|
|
||||||
@@ -227,11 +245,56 @@ pub fn read_file(
|
|||||||
|
|
||||||
let content = fs::read_to_string(&absolute_path)?;
|
let content = fs::read_to_string(&absolute_path)?;
|
||||||
let lines: Vec<&str> = content.lines().collect();
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
let start_index = offset.unwrap_or(0).min(lines.len());
|
let total_lines = lines.len();
|
||||||
let end_index = limit.map_or(lines.len(), |limit| {
|
|
||||||
start_index.saturating_add(limit).min(lines.len())
|
// Special handling for conflicts selector
|
||||||
});
|
if selector == Some("conflicts") {
|
||||||
let selected = lines[start_index..end_index].join("\n");
|
let conflicts = extract_merge_conflicts(&lines);
|
||||||
|
let (selected, num_lines) = if conflicts.is_empty() {
|
||||||
|
(format!("No unresolved git merge conflicts in {}.", path), 1)
|
||||||
|
} else {
|
||||||
|
let display_path = absolute_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or(path);
|
||||||
|
let summary = format_conflict_summary(&conflicts, display_path);
|
||||||
|
let summary_lines = summary.lines().count();
|
||||||
|
(summary, summary_lines)
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(ReadFileOutput {
|
||||||
|
kind: String::from("text"),
|
||||||
|
file: TextFilePayload {
|
||||||
|
file_path: absolute_path.to_string_lossy().into_owned(),
|
||||||
|
content: selected,
|
||||||
|
num_lines,
|
||||||
|
start_line: 1,
|
||||||
|
total_lines,
|
||||||
|
},
|
||||||
|
truncation_footer: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which lines to select
|
||||||
|
let (start_index, end_index, truncation_footer) = if let Some(sel) = selector {
|
||||||
|
// Parse and apply selector
|
||||||
|
let parsed = line_range_selector::parse_selector(sel)
|
||||||
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?;
|
||||||
|
apply_line_selector(&lines, parsed, path)
|
||||||
|
} else {
|
||||||
|
// Use offset/limit for backward compatibility
|
||||||
|
let start = offset.unwrap_or(0).min(lines.len());
|
||||||
|
let end = limit.map_or(lines.len(), |lim| {
|
||||||
|
start.saturating_add(lim).min(lines.len())
|
||||||
|
});
|
||||||
|
(start, end, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let selected = if start_index < end_index {
|
||||||
|
lines[start_index..end_index].join("\n")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
Ok(ReadFileOutput {
|
Ok(ReadFileOutput {
|
||||||
kind: String::from("text"),
|
kind: String::from("text"),
|
||||||
@@ -240,11 +303,255 @@ pub fn read_file(
|
|||||||
content: selected,
|
content: selected,
|
||||||
num_lines: end_index.saturating_sub(start_index),
|
num_lines: end_index.saturating_sub(start_index),
|
||||||
start_line: start_index.saturating_add(1),
|
start_line: start_index.saturating_add(1),
|
||||||
total_lines: lines.len(),
|
total_lines,
|
||||||
},
|
},
|
||||||
|
truncation_footer,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents a merge conflict block in a file.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct ConflictBlock {
|
||||||
|
start_line: usize, // 1-based line number where <<<<<<< starts
|
||||||
|
separator_line: usize, // 1-based line number where ======= is
|
||||||
|
end_line: usize, // 1-based line number where >>>>>>> is
|
||||||
|
base_line: Option<usize>, // 1-based line number where ||||||| is (for 3-way)
|
||||||
|
ours_label: Option<String>,
|
||||||
|
base_label: Option<String>,
|
||||||
|
theirs_label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scans lines for unresolved git merge conflict blocks.
|
||||||
|
fn extract_merge_conflicts(lines: &[&str]) -> Vec<ConflictBlock> {
|
||||||
|
let mut blocks = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
const OURS_PREFIX: &str = "<<<<<<<";
|
||||||
|
const BASE_PREFIX: &str = "|||||||";
|
||||||
|
const SEPARATOR: &str = "=======";
|
||||||
|
const THEIRS_PREFIX: &str = ">>>>>>>";
|
||||||
|
|
||||||
|
while i < lines.len() {
|
||||||
|
let line = lines[i];
|
||||||
|
|
||||||
|
// Check for start of conflict block
|
||||||
|
if let Some(label) = extract_marker_label(line, OURS_PREFIX) {
|
||||||
|
let ours_label = if label.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(label.to_string())
|
||||||
|
};
|
||||||
|
let start_line = i + 1; // Convert to 1-based
|
||||||
|
let mut separator_line = None;
|
||||||
|
let mut base_line = None;
|
||||||
|
let mut base_label = None;
|
||||||
|
let mut end_line = None;
|
||||||
|
let mut theirs_label = None;
|
||||||
|
let mut j = i + 1;
|
||||||
|
|
||||||
|
// Look for separator, optional base marker, and end marker
|
||||||
|
while j < lines.len() {
|
||||||
|
let current = lines[j];
|
||||||
|
|
||||||
|
// Check for base marker (3-way)
|
||||||
|
if let Some(label) = extract_marker_label(current, BASE_PREFIX) {
|
||||||
|
if base_line.is_none() {
|
||||||
|
base_line = Some(j + 1);
|
||||||
|
base_label = if label.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(label.to_string())
|
||||||
|
};
|
||||||
|
j += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for separator
|
||||||
|
if current.trim() == SEPARATOR {
|
||||||
|
if separator_line.is_none() {
|
||||||
|
separator_line = Some(j + 1);
|
||||||
|
j += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for end marker
|
||||||
|
if let Some(label) = extract_marker_label(current, THEIRS_PREFIX) {
|
||||||
|
if separator_line.is_some() {
|
||||||
|
end_line = Some(j + 1);
|
||||||
|
theirs_label = if label.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(label.to_string())
|
||||||
|
};
|
||||||
|
j += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add if we found both separator and end marker
|
||||||
|
if let (Some(sep_line), Some(end)) = (separator_line, end_line) {
|
||||||
|
blocks.push(ConflictBlock {
|
||||||
|
start_line,
|
||||||
|
separator_line: sep_line,
|
||||||
|
end_line: end,
|
||||||
|
base_line,
|
||||||
|
ours_label,
|
||||||
|
base_label,
|
||||||
|
theirs_label,
|
||||||
|
});
|
||||||
|
i = j;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the optional label from a marker line.
|
||||||
|
/// Returns the label text (stripped of leading/trailing spaces) if the line matches the prefix,
|
||||||
|
/// or None otherwise.
|
||||||
|
fn extract_marker_label<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
|
||||||
|
if line.starts_with(prefix) {
|
||||||
|
let rest = &line[prefix.len()..];
|
||||||
|
// Accept either just the prefix or prefix + single space + label
|
||||||
|
if rest.is_empty() {
|
||||||
|
Some("")
|
||||||
|
} else if rest.starts_with(' ') {
|
||||||
|
Some(rest[1..].trim())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format conflict blocks as a summary index, like GJC's implementation.
|
||||||
|
fn format_conflict_summary(conflicts: &[ConflictBlock], display_path: &str) -> String {
|
||||||
|
let mut output = Vec::new();
|
||||||
|
let total = conflicts.len();
|
||||||
|
let word = if total == 1 { "conflict" } else { "conflicts" };
|
||||||
|
|
||||||
|
output.push(format!(
|
||||||
|
"⚠ {} unresolved {} in {}",
|
||||||
|
total, word, display_path
|
||||||
|
));
|
||||||
|
|
||||||
|
// Extract labels for header
|
||||||
|
let ours_label = conflicts.iter().find_map(|c| c.ours_label.as_ref());
|
||||||
|
let theirs_label = conflicts.iter().find_map(|c| c.theirs_label.as_ref());
|
||||||
|
let base_label = conflicts.iter().find_map(|c| c.base_label.as_ref());
|
||||||
|
let any_base = conflicts.iter().any(|c| c.base_line.is_some());
|
||||||
|
|
||||||
|
if let Some(label) = ours_label {
|
||||||
|
output.push(format!("- ours = {}", label));
|
||||||
|
}
|
||||||
|
if let Some(label) = theirs_label {
|
||||||
|
output.push(format!("- theirs = {}", label));
|
||||||
|
}
|
||||||
|
if any_base {
|
||||||
|
let base_name = base_label
|
||||||
|
.map(|l| l.to_string())
|
||||||
|
.unwrap_or_else(|| "(no label)".to_string());
|
||||||
|
output.push(format!("- base = {}", base_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push(
|
||||||
|
"NOTICE: Bulk-resolve with `write({ path: \"conflict://*\", content })`, or address a single block with `write({ path: \"conflict://<N>\", content })`. Inspect a block by reading `conflict://<N>` (add `/ours` / `/theirs` / `/base` for a single side).".to_string()
|
||||||
|
);
|
||||||
|
output.push(
|
||||||
|
"`content` shorthand: `@ours` / `@theirs` / `@base` / `@both` lines expand to the recorded sections; `@both` = ours-then-theirs. Non-token lines pass through verbatim.".to_string()
|
||||||
|
);
|
||||||
|
output.push(String::new());
|
||||||
|
|
||||||
|
// Format each conflict as a one-liner index
|
||||||
|
let id_width = total.to_string().len();
|
||||||
|
for (idx, conflict) in conflicts.iter().enumerate() {
|
||||||
|
let id = idx + 1;
|
||||||
|
let range = if conflict.start_line == conflict.end_line {
|
||||||
|
format!("L{}", conflict.start_line)
|
||||||
|
} else {
|
||||||
|
format!("L{}-{}", conflict.start_line, conflict.end_line)
|
||||||
|
};
|
||||||
|
let kind = if conflict.base_line.is_some() {
|
||||||
|
" (3-way)"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
output.push(format!(
|
||||||
|
"#{:>width$} {}{}",
|
||||||
|
id,
|
||||||
|
range,
|
||||||
|
kind,
|
||||||
|
width = id_width
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
output.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a parsed line selector to file lines and return the selected range.
|
||||||
|
///
|
||||||
|
/// Returns (start_index, end_index, truncation_footer).
|
||||||
|
fn apply_line_selector(
|
||||||
|
lines: &[&str],
|
||||||
|
selector: LineRangeSelector,
|
||||||
|
_file_path: &str,
|
||||||
|
) -> (usize, usize, Option<String>) {
|
||||||
|
match selector {
|
||||||
|
LineRangeSelector::Raw => {
|
||||||
|
// Return entire file without truncation footer for raw mode
|
||||||
|
(0, lines.len(), None)
|
||||||
|
}
|
||||||
|
LineRangeSelector::Conflicts => {
|
||||||
|
// Conflicts are handled specially in read_file_with_selector, so this shouldn't be reached
|
||||||
|
(0, lines.len(), None)
|
||||||
|
}
|
||||||
|
LineRangeSelector::Lines(ranges) => {
|
||||||
|
// Collect all lines specified by ranges
|
||||||
|
let mut selected_indices = std::collections::BTreeSet::new();
|
||||||
|
for range in ranges {
|
||||||
|
let (start_zero, end_zero) = range.zero_indexed();
|
||||||
|
for i in start_zero..=end_zero.min(lines.len().saturating_sub(1)) {
|
||||||
|
selected_indices.insert(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected_indices.is_empty() {
|
||||||
|
return (0, 0, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find contiguous blocks and return the first one (or could merge all)
|
||||||
|
let indices: Vec<usize> = selected_indices.iter().copied().collect();
|
||||||
|
let first = indices[0];
|
||||||
|
let last = indices[indices.len() - 1];
|
||||||
|
|
||||||
|
// Check if it's a contiguous range
|
||||||
|
let is_contiguous = (last - first + 1) == indices.len();
|
||||||
|
|
||||||
|
if is_contiguous {
|
||||||
|
(first, last + 1, None)
|
||||||
|
} else {
|
||||||
|
// Return all selected indices as one continuous block from first to last
|
||||||
|
// In the future, this could be improved to show multiple blocks
|
||||||
|
(first, last + 1, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LineRangeSelector::None => {
|
||||||
|
// No selector, return entire file
|
||||||
|
(0, lines.len(), None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Replaces a file's contents and returns patch metadata.
|
/// Replaces a file's contents and returns patch metadata.
|
||||||
pub fn write_file(path: &str, content: &str) -> io::Result<WriteFileOutput> {
|
pub fn write_file(path: &str, content: &str) -> io::Result<WriteFileOutput> {
|
||||||
if content.len() > MAX_WRITE_SIZE {
|
if content.len() > MAX_WRITE_SIZE {
|
||||||
@@ -715,11 +1022,23 @@ pub fn read_file_in_workspace(
|
|||||||
offset: Option<usize>,
|
offset: Option<usize>,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
workspace_root: &Path,
|
workspace_root: &Path,
|
||||||
|
) -> io::Result<ReadFileOutput> {
|
||||||
|
read_file_in_workspace_with_selector(path, offset, limit, None, workspace_root)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a file with workspace boundary enforcement and optional line range selector.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn read_file_in_workspace_with_selector(
|
||||||
|
path: &str,
|
||||||
|
offset: Option<usize>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
selector: Option<&str>,
|
||||||
|
workspace_root: &Path,
|
||||||
) -> io::Result<ReadFileOutput> {
|
) -> io::Result<ReadFileOutput> {
|
||||||
let absolute_path = normalize_path(path)?;
|
let absolute_path = normalize_path(path)?;
|
||||||
let canonical_root = canonicalize_workspace_root(workspace_root);
|
let canonical_root = canonicalize_workspace_root(workspace_root);
|
||||||
validate_workspace_boundary(&absolute_path, &canonical_root)?;
|
validate_workspace_boundary(&absolute_path, &canonical_root)?;
|
||||||
read_file(path, offset, limit)
|
read_file_with_selector(path, offset, limit, selector)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write a file with workspace boundary enforcement.
|
/// Write a file with workspace boundary enforcement.
|
||||||
@@ -1183,4 +1502,189 @@ mod tests {
|
|||||||
assert!(component_contains_glob("*.rs"));
|
assert!(component_contains_glob("*.rs"));
|
||||||
assert!(!component_contains_glob("src"));
|
assert!(!component_contains_glob("src"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_with_selector_single_line() {
|
||||||
|
let path = temp_path("selector-single.txt");
|
||||||
|
write_file(
|
||||||
|
path.to_string_lossy().as_ref(),
|
||||||
|
"line1\nline2\nline3\nline4\nline5",
|
||||||
|
)
|
||||||
|
.expect("write should succeed");
|
||||||
|
|
||||||
|
let output =
|
||||||
|
super::read_file_with_selector(path.to_string_lossy().as_ref(), None, None, Some("2"))
|
||||||
|
.expect("read with selector should succeed");
|
||||||
|
assert_eq!(output.file.content, "line2");
|
||||||
|
assert_eq!(output.file.start_line, 2);
|
||||||
|
assert_eq!(output.file.num_lines, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_with_selector_range() {
|
||||||
|
let path = temp_path("selector-range.txt");
|
||||||
|
write_file(
|
||||||
|
path.to_string_lossy().as_ref(),
|
||||||
|
"line1\nline2\nline3\nline4\nline5",
|
||||||
|
)
|
||||||
|
.expect("write should succeed");
|
||||||
|
|
||||||
|
let output = super::read_file_with_selector(
|
||||||
|
path.to_string_lossy().as_ref(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("2-4"),
|
||||||
|
)
|
||||||
|
.expect("read with selector should succeed");
|
||||||
|
assert_eq!(output.file.content, "line2\nline3\nline4");
|
||||||
|
assert_eq!(output.file.start_line, 2);
|
||||||
|
assert_eq!(output.file.num_lines, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_with_selector_plus_count() {
|
||||||
|
let path = temp_path("selector-plus.txt");
|
||||||
|
write_file(
|
||||||
|
path.to_string_lossy().as_ref(),
|
||||||
|
"line1\nline2\nline3\nline4\nline5",
|
||||||
|
)
|
||||||
|
.expect("write should succeed");
|
||||||
|
|
||||||
|
let output = super::read_file_with_selector(
|
||||||
|
path.to_string_lossy().as_ref(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("2+2"),
|
||||||
|
)
|
||||||
|
.expect("read with selector should succeed");
|
||||||
|
assert_eq!(output.file.content, "line2\nline3");
|
||||||
|
assert_eq!(output.file.start_line, 2);
|
||||||
|
assert_eq!(output.file.num_lines, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_with_selector_raw() {
|
||||||
|
let path = temp_path("selector-raw.txt");
|
||||||
|
write_file(
|
||||||
|
path.to_string_lossy().as_ref(),
|
||||||
|
"line1\nline2\nline3\nline4\nline5",
|
||||||
|
)
|
||||||
|
.expect("write should succeed");
|
||||||
|
|
||||||
|
let output = super::read_file_with_selector(
|
||||||
|
path.to_string_lossy().as_ref(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("raw"),
|
||||||
|
)
|
||||||
|
.expect("read with selector should succeed");
|
||||||
|
assert_eq!(output.file.content, "line1\nline2\nline3\nline4\nline5");
|
||||||
|
assert_eq!(output.file.start_line, 1);
|
||||||
|
assert_eq!(output.file.num_lines, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_with_selector_none() {
|
||||||
|
let path = temp_path("selector-none.txt");
|
||||||
|
write_file(
|
||||||
|
path.to_string_lossy().as_ref(),
|
||||||
|
"line1\nline2\nline3\nline4\nline5",
|
||||||
|
)
|
||||||
|
.expect("write should succeed");
|
||||||
|
|
||||||
|
let output =
|
||||||
|
super::read_file_with_selector(path.to_string_lossy().as_ref(), None, None, None)
|
||||||
|
.expect("read with selector should succeed");
|
||||||
|
assert_eq!(output.file.content, "line1\nline2\nline3\nline4\nline5");
|
||||||
|
assert_eq!(output.file.start_line, 1);
|
||||||
|
assert_eq!(output.file.num_lines, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_with_invalid_selector() {
|
||||||
|
let path = temp_path("selector-invalid.txt");
|
||||||
|
write_file(
|
||||||
|
path.to_string_lossy().as_ref(),
|
||||||
|
"line1\nline2\nline3\nline4\nline5",
|
||||||
|
)
|
||||||
|
.expect("write should succeed");
|
||||||
|
|
||||||
|
let result = super::read_file_with_selector(
|
||||||
|
path.to_string_lossy().as_ref(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("invalid-selector"),
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_with_selector_conflicts_no_conflicts() {
|
||||||
|
let path = temp_path("conflicts-none.txt");
|
||||||
|
write_file(
|
||||||
|
path.to_string_lossy().as_ref(),
|
||||||
|
"const a = 1;\nconst b = 2;\n",
|
||||||
|
)
|
||||||
|
.expect("write should succeed");
|
||||||
|
|
||||||
|
let output = super::read_file_with_selector(
|
||||||
|
path.to_string_lossy().as_ref(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("conflicts"),
|
||||||
|
)
|
||||||
|
.expect("read with conflicts selector should succeed");
|
||||||
|
assert!(output
|
||||||
|
.file
|
||||||
|
.content
|
||||||
|
.contains("No unresolved git merge conflicts"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_with_selector_conflicts_two_way() {
|
||||||
|
let path = temp_path("conflicts-two-way.txt");
|
||||||
|
let content = "<<<<<<< A\na-ours\n=======\na-theirs\n>>>>>>> A\nmiddle\n<<<<<<< B\nb-ours\n=======\nb-theirs\n>>>>>>> B\ntail";
|
||||||
|
write_file(path.to_string_lossy().as_ref(), content).expect("write should succeed");
|
||||||
|
|
||||||
|
let output = super::read_file_with_selector(
|
||||||
|
path.to_string_lossy().as_ref(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("conflicts"),
|
||||||
|
)
|
||||||
|
.expect("read with conflicts selector should succeed");
|
||||||
|
|
||||||
|
let text = &output.file.content;
|
||||||
|
// Should report 2 conflicts
|
||||||
|
assert!(text.contains("2 unresolved conflicts"), "text: {}", text);
|
||||||
|
// Should have indices for both blocks
|
||||||
|
assert!(text.contains("#1"), "missing #1: {}", text);
|
||||||
|
assert!(text.contains("#2"), "missing #2: {}", text);
|
||||||
|
// Should have line ranges
|
||||||
|
assert!(text.contains("L1-5"), "missing L1-5: {}", text);
|
||||||
|
assert!(text.contains("L7-11"), "missing L7-11: {}", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_with_selector_conflicts_three_way() {
|
||||||
|
let path = temp_path("conflicts-three-way.txt");
|
||||||
|
let content = "head\n<<<<<<< HEAD\nours body\n||||||| common ancestor\nbase body\n=======\ntheirs body\n>>>>>>> feat\ntail";
|
||||||
|
write_file(path.to_string_lossy().as_ref(), content).expect("write should succeed");
|
||||||
|
|
||||||
|
let output = super::read_file_with_selector(
|
||||||
|
path.to_string_lossy().as_ref(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("conflicts"),
|
||||||
|
)
|
||||||
|
.expect("read with conflicts selector should succeed");
|
||||||
|
|
||||||
|
let text = &output.file.content;
|
||||||
|
// Should report 1 conflict
|
||||||
|
assert!(text.contains("1 unresolved conflict"), "text: {}", text);
|
||||||
|
// Should mark as 3-way
|
||||||
|
assert!(text.contains("3-way"), "missing 3-way marker: {}", text);
|
||||||
|
// Should show line range
|
||||||
|
assert!(text.contains("L2-8"), "missing L2-8: {}", text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,7 @@ pub mod green_contract;
|
|||||||
mod hooks;
|
mod hooks;
|
||||||
mod json;
|
mod json;
|
||||||
mod lane_events;
|
mod lane_events;
|
||||||
|
pub mod line_range_selector;
|
||||||
pub mod lsp_client;
|
pub mod lsp_client;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
mod mcp_client;
|
mod mcp_client;
|
||||||
@@ -87,7 +88,8 @@ pub use conversation::{
|
|||||||
};
|
};
|
||||||
pub use file_ops::{
|
pub use file_ops::{
|
||||||
edit_file, edit_file_in_workspace, glob_search, glob_search_in_workspace, grep_search,
|
edit_file, edit_file_in_workspace, glob_search, glob_search_in_workspace, grep_search,
|
||||||
grep_search_in_workspace, read_file, read_file_in_workspace, write_file,
|
grep_search_in_workspace, read_file, read_file_in_workspace,
|
||||||
|
read_file_in_workspace_with_selector, read_file_with_selector, write_file,
|
||||||
write_file_in_workspace, EditFileOutput, GlobSearchOutput, GrepSearchInput, GrepSearchOutput,
|
write_file_in_workspace, EditFileOutput, GlobSearchOutput, GrepSearchInput, GrepSearchOutput,
|
||||||
ReadFileOutput, StructuredPatchHunk, TextFilePayload, WriteFileOutput,
|
ReadFileOutput, StructuredPatchHunk, TextFilePayload, WriteFileOutput,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
/// GJC-compatible line range selector parsing for the read tool.
|
||||||
|
///
|
||||||
|
/// Supports selector syntax:
|
||||||
|
/// - `:50` = start at line 50
|
||||||
|
/// - `:50-200` = lines 50-200 inclusive
|
||||||
|
/// - `:50+150` = 150 lines starting at 50
|
||||||
|
/// - `:50+1` = exactly one line
|
||||||
|
/// - `:5-16,960-973` = multiple ranges
|
||||||
|
/// - `:raw` = full verbatim
|
||||||
|
/// - `:conflicts` = git merge conflicts only
|
||||||
|
use std::num::ParseIntError;
|
||||||
|
|
||||||
|
/// Parsed line range specification.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum LineRangeSelector {
|
||||||
|
/// Full file verbatim (`:raw`)
|
||||||
|
Raw,
|
||||||
|
/// Git merge conflict sections only (`:conflicts`)
|
||||||
|
Conflicts,
|
||||||
|
/// Line range(s) (`:L-L+N` syntax)
|
||||||
|
Lines(Vec<LineRange>),
|
||||||
|
/// No selector (full file, may be truncated by default budget)
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single line range, zero-indexed internally but displayed as 1-indexed.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct LineRange {
|
||||||
|
/// Start line (1-indexed, user-facing)
|
||||||
|
pub start_line: usize,
|
||||||
|
/// End line inclusive (1-indexed, user-facing)
|
||||||
|
pub end_line: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LineRange {
|
||||||
|
/// Create a new line range (1-indexed).
|
||||||
|
pub fn new(start_line: usize, end_line: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
start_line,
|
||||||
|
end_line,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to zero-indexed range for internal use.
|
||||||
|
pub fn zero_indexed(&self) -> (usize, usize) {
|
||||||
|
(
|
||||||
|
self.start_line.saturating_sub(1),
|
||||||
|
self.end_line.saturating_sub(1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of lines in this range.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
if self.start_line <= self.end_line {
|
||||||
|
self.end_line - self.start_line + 1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this range is valid.
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
self.start_line > 0 && self.end_line > 0 && self.start_line <= self.end_line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse error types for line range selectors.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ParseError {
|
||||||
|
InvalidFormat(String),
|
||||||
|
InvalidNumber(ParseIntError),
|
||||||
|
InvalidRange(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ParseError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
ParseError::InvalidFormat(msg) => write!(f, "Invalid format: {}", msg),
|
||||||
|
ParseError::InvalidNumber(e) => write!(f, "Invalid number: {}", e),
|
||||||
|
ParseError::InvalidRange(msg) => write!(f, "Invalid range: {}", msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ParseError {}
|
||||||
|
|
||||||
|
impl From<ParseIntError> for ParseError {
|
||||||
|
fn from(e: ParseIntError) -> Self {
|
||||||
|
ParseError::InvalidNumber(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a line range selector string.
|
||||||
|
///
|
||||||
|
/// Examples:
|
||||||
|
/// - `"50"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 50)])`
|
||||||
|
/// - `"50-200"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 200)])`
|
||||||
|
/// - `"50+150"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 199)])`
|
||||||
|
/// - `"raw"` → `LineRangeSelector::Raw`
|
||||||
|
/// - `"conflicts"` → `LineRangeSelector::Conflicts`
|
||||||
|
pub fn parse_selector(selector: &str) -> Result<LineRangeSelector, ParseError> {
|
||||||
|
if selector.is_empty() {
|
||||||
|
return Ok(LineRangeSelector::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
match selector {
|
||||||
|
"raw" => Ok(LineRangeSelector::Raw),
|
||||||
|
"conflicts" => Ok(LineRangeSelector::Conflicts),
|
||||||
|
_ => parse_line_ranges(selector),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse comma-separated line ranges.
|
||||||
|
fn parse_line_ranges(selector: &str) -> Result<LineRangeSelector, ParseError> {
|
||||||
|
let mut ranges = Vec::new();
|
||||||
|
|
||||||
|
for range_str in selector.split(',') {
|
||||||
|
let range_str = range_str.trim();
|
||||||
|
if range_str.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let range = parse_single_range(range_str)?;
|
||||||
|
ranges.push(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ranges.is_empty() {
|
||||||
|
Ok(LineRangeSelector::None)
|
||||||
|
} else {
|
||||||
|
// Merge overlapping/adjacent ranges
|
||||||
|
ranges.sort_by_key(|r| r.start_line);
|
||||||
|
let merged = merge_ranges(ranges);
|
||||||
|
Ok(LineRangeSelector::Lines(merged))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a single line range in formats: "50", "50-200", "50+150".
|
||||||
|
fn parse_single_range(range_str: &str) -> Result<LineRange, ParseError> {
|
||||||
|
// Try "50+150" format first (start+count)
|
||||||
|
if let Some(plus_idx) = range_str.find('+') {
|
||||||
|
let start_str = &range_str[..plus_idx];
|
||||||
|
let count_str = &range_str[plus_idx + 1..];
|
||||||
|
|
||||||
|
if start_str.is_empty() || count_str.is_empty() {
|
||||||
|
return Err(ParseError::InvalidFormat(range_str.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = start_str.parse::<usize>()?;
|
||||||
|
let count = count_str.parse::<usize>()?;
|
||||||
|
|
||||||
|
if start == 0 || count == 0 {
|
||||||
|
return Err(ParseError::InvalidRange(
|
||||||
|
"Line numbers and counts must be >= 1".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(LineRange::new(start, start + count - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try "50-200" format (start-end)
|
||||||
|
if let Some(dash_idx) = range_str.find('-') {
|
||||||
|
// Skip leading dash (negative numbers aren't valid here)
|
||||||
|
if dash_idx == 0 {
|
||||||
|
return Err(ParseError::InvalidFormat(range_str.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let start_str = &range_str[..dash_idx];
|
||||||
|
let end_str = &range_str[dash_idx + 1..];
|
||||||
|
|
||||||
|
if start_str.is_empty() || end_str.is_empty() {
|
||||||
|
return Err(ParseError::InvalidFormat(range_str.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = start_str.parse::<usize>()?;
|
||||||
|
let end = end_str.parse::<usize>()?;
|
||||||
|
|
||||||
|
if start == 0 || end == 0 {
|
||||||
|
return Err(ParseError::InvalidRange(
|
||||||
|
"Line numbers must be >= 1".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if start > end {
|
||||||
|
return Err(ParseError::InvalidRange(format!(
|
||||||
|
"Start line {} cannot be greater than end line {}",
|
||||||
|
start, end
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(LineRange::new(start, end));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try plain "50" format (single line)
|
||||||
|
let line_num = range_str.parse::<usize>()?;
|
||||||
|
if line_num == 0 {
|
||||||
|
return Err(ParseError::InvalidRange(
|
||||||
|
"Line number must be >= 1".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(LineRange::new(line_num, line_num))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge overlapping and adjacent ranges.
|
||||||
|
fn merge_ranges(ranges: Vec<LineRange>) -> Vec<LineRange> {
|
||||||
|
let mut merged: Vec<LineRange> = Vec::new();
|
||||||
|
|
||||||
|
for range in ranges {
|
||||||
|
if let Some(last) = merged.last_mut() {
|
||||||
|
// If ranges overlap or are adjacent, merge them
|
||||||
|
if range.start_line <= last.end_line + 1 {
|
||||||
|
last.end_line = last.end_line.max(range.end_line);
|
||||||
|
} else {
|
||||||
|
merged.push(range);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
merged.push(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_raw() {
|
||||||
|
assert_eq!(parse_selector("raw").unwrap(), LineRangeSelector::Raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_conflicts() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_selector("conflicts").unwrap(),
|
||||||
|
LineRangeSelector::Conflicts
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_single_line() {
|
||||||
|
match parse_selector("50").unwrap() {
|
||||||
|
LineRangeSelector::Lines(ranges) => {
|
||||||
|
assert_eq!(ranges.len(), 1);
|
||||||
|
assert_eq!(ranges[0].start_line, 50);
|
||||||
|
assert_eq!(ranges[0].end_line, 50);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Lines variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_range_start_dash_end() {
|
||||||
|
match parse_selector("50-200").unwrap() {
|
||||||
|
LineRangeSelector::Lines(ranges) => {
|
||||||
|
assert_eq!(ranges.len(), 1);
|
||||||
|
assert_eq!(ranges[0].start_line, 50);
|
||||||
|
assert_eq!(ranges[0].end_line, 200);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Lines variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_range_start_plus_count() {
|
||||||
|
match parse_selector("50+150").unwrap() {
|
||||||
|
LineRangeSelector::Lines(ranges) => {
|
||||||
|
assert_eq!(ranges.len(), 1);
|
||||||
|
assert_eq!(ranges[0].start_line, 50);
|
||||||
|
assert_eq!(ranges[0].end_line, 199);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Lines variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_multiple_ranges() {
|
||||||
|
match parse_selector("5-16,960-973").unwrap() {
|
||||||
|
LineRangeSelector::Lines(ranges) => {
|
||||||
|
assert_eq!(ranges.len(), 2);
|
||||||
|
assert_eq!(ranges[0].start_line, 5);
|
||||||
|
assert_eq!(ranges[0].end_line, 16);
|
||||||
|
assert_eq!(ranges[1].start_line, 960);
|
||||||
|
assert_eq!(ranges[1].end_line, 973);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Lines variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_overlapping_ranges() {
|
||||||
|
// "5-16,10-20" should merge into "5-20"
|
||||||
|
match parse_selector("5-16,10-20").unwrap() {
|
||||||
|
LineRangeSelector::Lines(ranges) => {
|
||||||
|
assert_eq!(ranges.len(), 1);
|
||||||
|
assert_eq!(ranges[0].start_line, 5);
|
||||||
|
assert_eq!(ranges[0].end_line, 20);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Lines variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_adjacent_ranges() {
|
||||||
|
// "5-16,17-20" should merge into "5-20"
|
||||||
|
match parse_selector("5-16,17-20").unwrap() {
|
||||||
|
LineRangeSelector::Lines(ranges) => {
|
||||||
|
assert_eq!(ranges.len(), 1);
|
||||||
|
assert_eq!(ranges[0].start_line, 5);
|
||||||
|
assert_eq!(ranges[0].end_line, 20);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Lines variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_range_start_greater_than_end() {
|
||||||
|
let result = parse_selector("200-50");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_zero_line() {
|
||||||
|
let result = parse_selector("0");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_selector() {
|
||||||
|
assert_eq!(parse_selector("").unwrap(), LineRangeSelector::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_line_range_zero_indexed() {
|
||||||
|
let range = LineRange::new(50, 200);
|
||||||
|
let (start, end) = range.zero_indexed();
|
||||||
|
assert_eq!(start, 49);
|
||||||
|
assert_eq!(end, 199);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_line_range_len() {
|
||||||
|
assert_eq!(LineRange::new(50, 200).len(), 151);
|
||||||
|
assert_eq!(LineRange::new(50, 50).len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "sdk"
|
name = "sdk"
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use runtime::{
|
|||||||
lsp_client::LspRegistry,
|
lsp_client::LspRegistry,
|
||||||
mcp_tool_bridge::McpToolRegistry,
|
mcp_tool_bridge::McpToolRegistry,
|
||||||
permission_enforcer::{EnforcementResult, PermissionEnforcer},
|
permission_enforcer::{EnforcementResult, PermissionEnforcer},
|
||||||
read_file_in_workspace,
|
read_file_in_workspace_with_selector,
|
||||||
summary_compression::compress_summary_text,
|
summary_compression::compress_summary_text,
|
||||||
task_registry::TaskRegistry,
|
task_registry::TaskRegistry,
|
||||||
team_cron_registry::{CronRegistry, TeamRegistry},
|
team_cron_registry::{CronRegistry, TeamRegistry},
|
||||||
@@ -2569,8 +2569,14 @@ fn branch_divergence_output(
|
|||||||
fn run_read_file(input: ReadFileInput) -> Result<String, String> {
|
fn run_read_file(input: ReadFileInput) -> Result<String, String> {
|
||||||
let workspace = std::env::current_dir().map_err(|error| error.to_string())?;
|
let workspace = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||||
to_pretty_json(
|
to_pretty_json(
|
||||||
read_file_in_workspace(&input.path, input.offset, input.limit, &workspace)
|
read_file_in_workspace_with_selector(
|
||||||
.map_err(io_to_string)?,
|
&input.path,
|
||||||
|
input.offset,
|
||||||
|
input.limit,
|
||||||
|
input.selector.as_deref(),
|
||||||
|
&workspace,
|
||||||
|
)
|
||||||
|
.map_err(io_to_string)?,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2885,6 +2891,7 @@ struct ReadFileInput {
|
|||||||
path: String,
|
path: String,
|
||||||
offset: Option<usize>,
|
offset: Option<usize>,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
|
selector: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -10690,6 +10697,43 @@ printf 'pwsh:%s' "$1"
|
|||||||
let _ = fs::remove_dir_all(root);
|
let _ = fs::remove_dir_all(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn given_read_only_enforcer_when_read_file_with_selector_then_succeeds() {
|
||||||
|
let _guard = env_lock()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let root = temp_path("perm-read-selector");
|
||||||
|
fs::create_dir_all(&root).expect("create root");
|
||||||
|
let file = root.join("multi-line.txt");
|
||||||
|
fs::write(&file, "line1\nline2\nline3\nline4\nline5\n").expect("write test file");
|
||||||
|
let original_dir = std::env::current_dir().expect("cwd");
|
||||||
|
std::env::set_current_dir(&root).expect("set cwd");
|
||||||
|
|
||||||
|
let registry = read_only_registry();
|
||||||
|
let result = registry.execute(
|
||||||
|
"read_file",
|
||||||
|
&json!({
|
||||||
|
"path": file.display().to_string(),
|
||||||
|
"selector": "2-4"
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"read_file with selector should succeed: {result:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the output contains the expected lines
|
||||||
|
let output: serde_json::Value =
|
||||||
|
serde_json::from_str(&result.unwrap()).expect("json output");
|
||||||
|
let content = output["file"]["content"].as_str().expect("content field");
|
||||||
|
assert!(content.contains("line2"));
|
||||||
|
assert!(content.contains("line3"));
|
||||||
|
assert!(content.contains("line4"));
|
||||||
|
|
||||||
|
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn given_read_only_enforcer_when_glob_search_then_not_permission_denied() {
|
fn given_read_only_enforcer_when_glob_search_then_not_permission_denied() {
|
||||||
let registry = read_only_registry();
|
let registry = read_only_registry();
|
||||||
|
|||||||
Reference in new issue
Block a user