M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
ZacharyZhang-NY committed 2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 files changed
+1353757

No files matched your search

@@ -0,0 +1,876 @@
//! Diff computation using the `similar` crate.
use similar::{ChangeTag, TextDiff};
use std::fmt::Write;
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tracing::{debug, warn};
use crate::types::{Hunk, HunkId, HunkLineInfo, HunkSource};
/// Number of context lines to include around changes (like git diff).
const CONTEXT_LINES: usize = 3;
/// Maximum time allowed for a single diff computation.
const DIFF_TIMEOUT: Duration = Duration::from_secs(10);
/// Maximum file size (in bytes) to attempt diffing.
/// Files larger than this will be skipped to avoid pathological diff behavior.
const MAX_DIFF_FILE_SIZE: usize = 1024 * 1024; // 1 MB
/// Generate a unified diff patch string from baseline and current content.
/// This produces a patch that can be parsed by Pierre's `getSingularPatch`.
///
/// Returns None if:
/// - Content is identical
/// - Either file exceeds MAX_DIFF_FILE_SIZE
/// - Diff computation times out
pub fn generate_unified_patch(path: &Path, baseline: &str, current: &str) -> Option<String> {
// If content is identical, no patch needed
if baseline == current {
return None;
}
// Check file size limits
if baseline.len() > MAX_DIFF_FILE_SIZE || current.len() > MAX_DIFF_FILE_SIZE {
warn!(
path = %path.display(),
baseline_size = baseline.len(),
current_size = current.len(),
max_size = MAX_DIFF_FILE_SIZE,
"Skipping unified patch for file exceeding size limit"
);
return None;
}
let start_time = Instant::now();
let diff = TextDiff::configure()
.timeout(DIFF_TIMEOUT)
.diff_lines(baseline, current);
let elapsed = start_time.elapsed();
if elapsed >= DIFF_TIMEOUT {
warn!(
path = %path.display(),
elapsed_ms = elapsed.as_millis(),
"Unified patch diff timed out"
);
return None;
}
// Generate unified diff with file headers
let path_str = path.display().to_string();
let unified = diff
.unified_diff()
.context_radius(CONTEXT_LINES)
.header(&format!("a/{}", path_str), &format!("b/{}", path_str))
.to_string();
if unified.is_empty() {
None
} else {
Some(unified)
}
}
/// Generate a patch fragment for a single hunk with context lines.
/// Returns just the hunk portion (no file headers), e.g.:
/// "@@ -10,5 +10,7 @@\n context\n-old\n+new\n context\n"
pub fn generate_hunk_patch(baseline: &str, current: &str, hunk: &Hunk) -> String {
let old_lines: Vec<&str> = baseline.lines().collect();
let new_lines: Vec<&str> = current.lines().collect();
let mut output = String::new();
// Calculate context bounds (0-indexed)
let old_start_idx = hunk.line_info.old_start.saturating_sub(1);
let new_start_idx = hunk.line_info.new_start.saturating_sub(1);
// Context before the change
let context_before_start = old_start_idx.saturating_sub(CONTEXT_LINES);
let context_before_end = old_start_idx;
// Context after the change (in new file coordinates)
let changes_end_new = new_start_idx + hunk.line_info.new_count;
let context_after_start = changes_end_new;
let context_after_end = (changes_end_new + CONTEXT_LINES).min(new_lines.len());
// For old file, context after
let changes_end_old = old_start_idx + hunk.line_info.old_count;
let context_after_start_old = changes_end_old;
let context_after_end_old = (changes_end_old + CONTEXT_LINES).min(old_lines.len());
// Calculate total lines for header
let total_old_lines = (context_before_end - context_before_start)
+ hunk.line_info.old_count
+ (context_after_end_old - context_after_start_old);
let total_new_lines = (context_before_end - context_before_start)
+ hunk.line_info.new_count
+ (context_after_end - context_after_start);
// Hunk header (1-indexed)
let header_old_start = context_before_start + 1;
let header_new_start = context_before_start + 1; // Context is same in both
let _ = writeln!(
output,
"@@ -{},{} +{},{} @@",
header_old_start, total_old_lines, header_new_start, total_new_lines
);
// Context lines before
for i in context_before_start..context_before_end {
if let Some(line) = old_lines.get(i) {
let _ = writeln!(output, " {}", line);
}
}
// Deleted lines
if let Some(old_text) = &hunk.old_text {
for line in old_text.lines() {
let _ = writeln!(output, "-{}", line);
}
}
// Added lines
for line in hunk.new_text.lines() {
let _ = writeln!(output, "+{}", line);
}
// Context lines after (from new file since changes may have shifted things)
for i in context_after_start..context_after_end {
if let Some(line) = new_lines.get(i) {
let _ = writeln!(output, " {}", line);
}
}
output
}
/// Compute hunks by diffing baseline against current content.
/// Uses the `similar` crate for line-based diff.
///
/// Returns an empty vector if:
/// - Content is identical (no changes)
/// - Either file exceeds MAX_DIFF_FILE_SIZE
/// - Diff computation times out
pub fn compute_hunks(path: &Path, baseline: &str, current: &str, source: HunkSource) -> Vec<Hunk> {
// If content is identical, no hunks
if baseline == current {
return vec![];
}
// Check file size limits to avoid pathological diff behavior
let baseline_size = baseline.len();
let current_size = current.len();
if baseline_size > MAX_DIFF_FILE_SIZE || current_size > MAX_DIFF_FILE_SIZE {
warn!(
path = %path.display(),
baseline_size,
current_size,
max_size = MAX_DIFF_FILE_SIZE,
"Skipping diff for file exceeding size limit"
);
return vec![];
}
let start_time = Instant::now();
debug!(
path = %path.display(),
baseline_lines = baseline.lines().count(),
current_lines = current.lines().count(),
"Starting diff computation"
);
let diff = TextDiff::configure()
.timeout(DIFF_TIMEOUT)
.diff_lines(baseline, current);
let elapsed = start_time.elapsed();
// Check if we hit the timeout (similar crate returns partial results on timeout)
if elapsed >= DIFF_TIMEOUT {
warn!(
path = %path.display(),
elapsed_ms = elapsed.as_millis(),
timeout_ms = DIFF_TIMEOUT.as_millis(),
"Diff computation timed out, returning empty hunks"
);
return vec![];
}
debug!(
path = %path.display(),
elapsed_ms = elapsed.as_millis(),
"Diff computation completed"
);
let mut hunks = Vec::new();
// Track current position in old and new files (1-indexed for display)
let mut old_line = 1usize;
let mut new_line = 1usize;
// Accumulator for current hunk being built
let mut current_hunk: Option<HunkBuilder> = None;
for change in diff.iter_all_changes() {
match change.tag() {
ChangeTag::Equal => {
// Equal line - finalize any in-progress hunk
if let Some(builder) = current_hunk.take() {
hunks.push(builder.build(path, source));
}
old_line += 1;
new_line += 1;
}
ChangeTag::Delete => {
// Line exists in old, not in new
let hunk = current_hunk.get_or_insert_with(|| HunkBuilder::new(old_line, new_line));
hunk.add_old_line(change.value());
old_line += 1;
}
ChangeTag::Insert => {
// Line exists in new, not in old
let hunk = current_hunk.get_or_insert_with(|| HunkBuilder::new(old_line, new_line));
hunk.add_new_line(change.value());
new_line += 1;
}
}
}
// Finalize last hunk if any
if let Some(builder) = current_hunk.take() {
hunks.push(builder.build(path, source));
}
hunks
}
/// Helper to accumulate lines while building a hunk.
struct HunkBuilder {
old_start: usize,
new_start: usize,
old_lines: Vec<String>,
new_lines: Vec<String>,
}
impl HunkBuilder {
fn new(old_start: usize, new_start: usize) -> Self {
Self {
old_start,
new_start,
old_lines: Vec::new(),
new_lines: Vec::new(),
}
}
fn add_old_line(&mut self, line: &str) {
self.old_lines.push(line.to_string());
}
fn add_new_line(&mut self, line: &str) {
self.new_lines.push(line.to_string());
}
fn build(self, path: &Path, source: HunkSource) -> Hunk {
let old_text = if self.old_lines.is_empty() {
None
} else {
Some(self.old_lines.join(""))
};
let new_text = self.new_lines.join("");
Hunk {
id: HunkId::new(),
path: path.to_path_buf(),
line_info: HunkLineInfo {
old_start: self.old_start,
old_count: self.old_lines.len(),
new_start: self.new_start,
new_count: self.new_lines.len(),
},
source,
old_text,
new_text,
patch: None, // Patch is generated later when requested
created_at: chrono::Utc::now(),
selected: false,
}
}
}
/// Generate a unified diff string for display.
pub fn format_unified_diff(hunk: &Hunk) -> String {
let mut output = String::new();
// Header
let _ = writeln!(output, "--- a/{}", hunk.path.display());
let _ = writeln!(output, "+++ b/{}", hunk.path.display());
// Hunk header
let _ = writeln!(output, "{}", hunk.line_info);
// Content
if let Some(old_text) = &hunk.old_text {
for line in old_text.lines() {
let _ = writeln!(output, "-{}", line);
}
}
for line in hunk.new_text.lines() {
let _ = writeln!(output, "+{}", line);
}
output
}
/// Replace lines in content starting at `start_line` (1-indexed),
/// removing `remove_count` lines and inserting `insert_text`.
///
/// # Arguments
/// * `content` - The full file content to patch
/// * `start_line` - 1-indexed line number where patch begins
/// * `remove_count` - Number of lines to remove (can be 0 for pure insert)
/// * `insert_text` - Text to insert (can be empty for pure delete)
///
/// # Returns
/// The patched content
pub fn patch_lines(
content: &str,
start_line: usize,
remove_count: usize,
insert_text: &str,
) -> String {
let lines: Vec<&str> = content.lines().collect();
let start_idx = start_line.saturating_sub(1); // Convert to 0-indexed
let mut result = Vec::new();
// Lines before the patch point
result.extend(lines[..start_idx.min(lines.len())].iter().copied());
// Insert new lines (if any)
if !insert_text.is_empty() {
for line in insert_text.lines() {
result.push(line);
}
}
// Lines after the removed section
let end_idx = (start_idx + remove_count).min(lines.len());
result.extend(lines[end_idx..].iter().copied());
// Reconstruct with proper trailing newline handling
let mut output = result.join("\n");
if content.ends_with('\n') && !output.is_empty() {
output.push('\n');
}
output
}
/// Compare two hunks to see if they represent the same logical change
/// (content match, possibly at different positions).
pub fn hunks_match_content(a: &Hunk, b: &Hunk) -> bool {
a.path == b.path && a.old_text == b.old_text && a.new_text == b.new_text
}
/// Check if a hunk has moved (same content, different position).
pub fn hunk_moved(old: &Hunk, new: &Hunk) -> bool {
hunks_match_content(old, new) && old.line_info != new.line_info
}
/// Check if two hunks overlap by line range in the baseline (old) file.
/// Uses old_start/old_count for stable overlap detection even when file shifts.
/// Used for determining when hunks should be merged or matched.
pub fn hunks_overlap(a: &Hunk, b: &Hunk) -> bool {
if a.path != b.path {
return false;
}
// Use old_start/old_count (baseline-relative) for stable overlap detection
let a_start = a.line_info.old_start;
let a_end = a.line_info.old_start.saturating_add(a.line_info.old_count);
let b_start = b.line_info.old_start;
let b_end = b.line_info.old_start.saturating_add(b.line_info.old_count);
// For pure insertions (old_count=0), consider adjacent positions as overlapping
if a.line_info.old_count == 0 && b.line_info.old_count == 0 {
// Two insertions at the same baseline position overlap
return a_start == b_start;
}
// Handle insertions overlapping with regular hunks:
// An insertion at position X overlaps with a hunk spanning [start, end) if start <= X <= end
if a.line_info.old_count == 0 {
// a is an insertion at a_start
return a_start >= b_start && a_start <= b_end;
}
if b.line_info.old_count == 0 {
// b is an insertion at b_start
return b_start >= a_start && b_start <= a_end;
}
// Overlaps if NOT (a ends before b starts OR b ends before a starts)
// Include adjacent (touching) hunks as overlapping
!(a_end < b_start || b_end < a_start)
}
/// Find the best matching old hunk for a new hunk.
/// Priority: 1) exact content + position match, 2) content match closest by line, 3) maximum overlap size
pub fn find_matching_old_hunk<'a>(
new_hunk: &Hunk,
old_hunks: &'a [Arc<Hunk>],
) -> Option<&'a Arc<Hunk>> {
// Collect all content matches
let content_matches: Vec<_> = old_hunks
.iter()
.filter(|o| hunks_match_content(o, new_hunk))
.collect();
if !content_matches.is_empty() {
// If we have content matches, pick the one closest by line position
// This handles the case of identical changes at multiple locations (e.g., variable rename)
return content_matches
.into_iter()
.min_by_key(|o| o.line_info.new_start.abs_diff(new_hunk.line_info.new_start));
}
// Fall back to BEST overlapping hunk (max overlap size)
old_hunks
.iter()
.filter(|o| hunks_overlap(o, new_hunk))
.max_by_key(|o| calculate_overlap_size(&o.line_info, &new_hunk.line_info))
}
/// Calculate the overlap size between two hunks (in baseline lines)
fn calculate_overlap_size(a: &HunkLineInfo, b: &HunkLineInfo) -> usize {
let a_start = a.old_start;
let a_end = a.old_start + a.old_count;
let b_start = b.old_start;
let b_end = b.old_start + b.old_count;
let overlap_start = a_start.max(b_start);
let overlap_end = a_end.min(b_end);
overlap_end.saturating_sub(overlap_start)
}
/// Find all old hunks that overlap with a new hunk (for merging).
pub fn find_overlapping_hunks<'a>(
new_hunk: &Hunk,
old_hunks: &'a [Arc<Hunk>],
) -> Vec<&'a Arc<Hunk>> {
old_hunks
.iter()
.filter(|o| hunks_overlap(o, new_hunk))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn agent_source() -> HunkSource {
HunkSource::AgentEdit { prompt_index: 0 }
}
#[test]
fn test_no_changes() {
let content = "line 1\nline 2\nline 3\n";
let hunks = compute_hunks(Path::new("test.rs"), content, content, agent_source());
assert!(hunks.is_empty());
}
#[test]
fn test_single_line_modification() {
let baseline = "line 1\nline 2\nline 3\n";
let current = "line 1\nmodified\nline 3\n";
let hunks = compute_hunks(Path::new("test.rs"), baseline, current, agent_source());
assert_eq!(hunks.len(), 1);
assert_eq!(hunks[0].old_text, Some("line 2\n".to_string()));
assert_eq!(hunks[0].new_text, "modified\n");
assert_eq!(hunks[0].line_info.old_start, 2);
assert_eq!(hunks[0].line_info.new_start, 2);
}
#[test]
fn test_insertion() {
let baseline = "line 1\nline 2\n";
let current = "line 1\ninserted\nline 2\n";
let hunks = compute_hunks(Path::new("test.rs"), baseline, current, agent_source());
assert_eq!(hunks.len(), 1);
assert_eq!(hunks[0].old_text, None);
assert_eq!(hunks[0].new_text, "inserted\n");
assert_eq!(hunks[0].line_info.old_count, 0);
assert_eq!(hunks[0].line_info.new_count, 1);
}
#[test]
fn test_deletion() {
let baseline = "line 1\nline 2\nline 3\n";
let current = "line 1\nline 3\n";
let hunks = compute_hunks(Path::new("test.rs"), baseline, current, agent_source());
assert_eq!(hunks.len(), 1);
assert_eq!(hunks[0].old_text, Some("line 2\n".to_string()));
assert_eq!(hunks[0].new_text, "");
assert_eq!(hunks[0].line_info.old_count, 1);
assert_eq!(hunks[0].line_info.new_count, 0);
}
#[test]
fn test_multiple_hunks() {
let baseline = "line 1\nline 2\nline 3\nline 4\nline 5\n";
let current = "modified 1\nline 2\nline 3\nline 4\nmodified 5\n";
let hunks = compute_hunks(Path::new("test.rs"), baseline, current, agent_source());
assert_eq!(hunks.len(), 2);
assert_eq!(hunks[0].line_info.old_start, 1);
assert_eq!(hunks[1].line_info.old_start, 5);
}
#[test]
fn test_format_unified_diff() {
let hunk = Hunk {
id: HunkId::new(),
path: "test.rs".into(),
line_info: HunkLineInfo {
old_start: 2,
old_count: 1,
new_start: 2,
new_count: 1,
},
source: agent_source(),
old_text: Some("old line\n".to_string()),
new_text: "new line\n".to_string(),
patch: None,
created_at: chrono::Utc::now(),
selected: false,
};
let diff = format_unified_diff(&hunk);
assert!(diff.contains("--- a/test.rs"));
assert!(diff.contains("+++ b/test.rs"));
assert!(diff.contains("-old line"));
assert!(diff.contains("+new line"));
}
#[test]
fn test_find_matching_hunk_with_identical_content_at_different_positions() {
// Simulate a variable rename that appears at multiple locations
// Old hunks at lines 10 and 100 with identical content
let old_hunk_at_10 = Arc::new(Hunk {
id: HunkId::from_string("hunk-10".to_string()),
path: "test.rs".into(),
line_info: HunkLineInfo {
old_start: 10,
old_count: 1,
new_start: 10,
new_count: 1,
},
source: agent_source(),
old_text: Some("let a = b;\n".to_string()),
new_text: "let c = b;\n".to_string(),
patch: None,
created_at: chrono::Utc::now(),
selected: false,
});
let old_hunk_at_100 = Arc::new(Hunk {
id: HunkId::from_string("hunk-100".to_string()),
path: "test.rs".into(),
line_info: HunkLineInfo {
old_start: 100,
old_count: 1,
new_start: 100,
new_count: 1,
},
source: agent_source(),
old_text: Some("let a = b;\n".to_string()),
new_text: "let c = b;\n".to_string(),
patch: None,
created_at: chrono::Utc::now(),
selected: false,
});
let old_hunks = vec![old_hunk_at_10.clone(), old_hunk_at_100.clone()];
// New hunk at line 10 should match old hunk at line 10
let new_hunk_near_10 = Hunk {
id: HunkId::new(),
path: "test.rs".into(),
line_info: HunkLineInfo {
old_start: 10,
old_count: 1,
new_start: 12, // slightly shifted
new_count: 1,
},
source: agent_source(),
old_text: Some("let a = b;\n".to_string()),
new_text: "let c = b;\n".to_string(),
patch: None,
created_at: chrono::Utc::now(),
selected: false,
};
let matched = find_matching_old_hunk(&new_hunk_near_10, &old_hunks);
assert!(matched.is_some());
assert_eq!(matched.unwrap().id.as_str(), "hunk-10");
// New hunk at line 100 should match old hunk at line 100
let new_hunk_near_100 = Hunk {
id: HunkId::new(),
path: "test.rs".into(),
line_info: HunkLineInfo {
old_start: 100,
old_count: 1,
new_start: 102, // slightly shifted
new_count: 1,
},
source: agent_source(),
old_text: Some("let a = b;\n".to_string()),
new_text: "let c = b;\n".to_string(),
patch: None,
created_at: chrono::Utc::now(),
selected: false,
};
let matched = find_matching_old_hunk(&new_hunk_near_100, &old_hunks);
assert!(matched.is_some());
assert_eq!(matched.unwrap().id.as_str(), "hunk-100");
}
#[test]
fn test_find_matching_hunk_fallback_best_overlap() {
// This test figures out the edge case mentioned: when a new hunk overlaps
// *multiple* old hunks (and no content match, so fallback), the current
// .find() picks the *first* overlapping one -- order-dependent, can preserve
// wrong hunk ID/source.
//
// We use different overlap sizes so "best" (max overlap) is unambiguous.
// With current code, this test FAILS (picks "small" because it's first).
// After fix to use max overlap, it should PASS (picks "large").
// Old hunks with no content match to new_hunk, ordered small-first
let old_hunk_small = Arc::new(Hunk {
id: HunkId::from_string("hunk-small".to_string()),
path: "test.rs".into(),
line_info: HunkLineInfo {
old_start: 1,
old_count: 1,
new_start: 1,
new_count: 2, // covers new lines 1-2
},
source: agent_source(),
old_text: Some("old-small\n".to_string()),
new_text: "new-small\n".to_string(),
patch: None,
created_at: chrono::Utc::now(),
selected: false,
});
let old_hunk_large = Arc::new(Hunk {
id: HunkId::from_string("hunk-large".to_string()),
path: "test.rs".into(),
line_info: HunkLineInfo {
old_start: 3,
old_count: 1,
new_start: 3,
new_count: 4, // covers new lines 3-6
},
source: agent_source(),
old_text: Some("old-large\n".to_string()),
new_text: "new-large\n".to_string(),
patch: None,
created_at: chrono::Utc::now(),
selected: false,
});
let old_hunks = vec![old_hunk_small.clone(), old_hunk_large.clone()]; // small first!
// New hunk overlaps both, but more with large:
// new lines 2-5 (end=6)
// - small: overlap lines 2 (size=1)
// - large: overlap lines 3-5 (size=3)
// Content differs -> no content match -> fallback to overlap
let new_hunk = Hunk {
id: HunkId::new(),
path: "test.rs".into(),
line_info: HunkLineInfo {
old_start: 2,
old_count: 4,
new_start: 2,
new_count: 4, // lines 2-5
},
source: agent_source(),
old_text: Some("different-old\n".to_string()),
new_text: "different-new\n".to_string(),
patch: None,
created_at: chrono::Utc::now(),
selected: false,
};
let matched = find_matching_old_hunk(&new_hunk, &old_hunks);
assert!(matched.is_some(), "Should find an overlapping hunk");
// EXPECTS BEST MATCH: large overlap, NOT the first one
// (this currently FAILS with .find(), proving the bug)
assert_eq!(
matched.unwrap().id.as_str(),
"hunk-large",
"Should pick hunk with largest overlap size, not first in list"
);
}
#[test]
fn test_patch_lines_basic() {
let content = "line 1\nline 2\nline 3\nline 4\nline 5\n";
// Replace line 2 with "CHANGED"
let patched = super::patch_lines(content, 2, 1, "CHANGED\n");
assert_eq!(patched, "line 1\nCHANGED\nline 3\nline 4\nline 5\n");
}
#[test]
fn test_patch_lines_no_trailing_newline_in_insert() {
let content = "line 1\nline 2\nline 3\n";
// Replace line 2 with "CHANGED" (no trailing newline in insert text)
let patched = super::patch_lines(content, 2, 1, "CHANGED");
assert_eq!(patched, "line 1\nCHANGED\nline 3\n");
}
#[test]
fn test_patch_lines_pure_insert() {
let content = "line 1\nline 2\nline 3\n";
// Insert at line 2 without removing anything
let patched = super::patch_lines(content, 2, 0, "INSERTED\n");
assert_eq!(patched, "line 1\nINSERTED\nline 2\nline 3\n");
}
#[test]
fn test_patch_lines_pure_delete() {
let content = "line 1\nline 2\nline 3\n";
// Delete line 2 without inserting anything
let patched = super::patch_lines(content, 2, 1, "");
assert_eq!(patched, "line 1\nline 3\n");
}
#[test]
fn test_patch_lines_multiple_lines() {
let content = "line 1\nline 2\nline 3\nline 4\nline 5\n";
// Replace lines 2-3 with 2 new lines
let patched = super::patch_lines(content, 2, 2, "NEW A\nNEW B\n");
assert_eq!(patched, "line 1\nNEW A\nNEW B\nline 4\nline 5\n");
}
#[test]
fn test_generate_hunk_patch_includes_context_and_headers() {
let baseline = "line 1\nline 2\nline 3\nline 4\nline 5\n";
let current = "line 1\nline 2\nchanged line 3\nline 4\nline 5\n";
let hunk = Hunk {
id: HunkId::new(),
path: "test.rs".into(),
line_info: HunkLineInfo {
old_start: 3,
old_count: 1,
new_start: 3,
new_count: 1,
},
source: agent_source(),
old_text: Some("line 3\n".to_string()),
new_text: "changed line 3\n".to_string(),
patch: None,
created_at: chrono::Utc::now(),
selected: false,
};
let patch = generate_hunk_patch(baseline, current, &hunk);
assert!(patch.starts_with("@@ -1,5 +1,5 @@"));
assert!(patch.contains(" line 1"));
assert!(patch.contains(" line 2"));
assert!(patch.contains("-line 3"));
assert!(patch.contains("+changed line 3"));
assert!(patch.contains(" line 4"));
assert!(patch.contains(" line 5"));
}
#[test]
fn test_generate_hunk_patch_multiple_hunks_with_add_and_delete() {
let baseline = "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\n";
let current = "line 1\nline 2\nadded line 2a\nline 3\nline 5\nline 6\nline 7\n";
let hunks = compute_hunks(Path::new("test.rs"), baseline, current, agent_source());
assert_eq!(hunks.len(), 2, "Should have one add and one delete hunk");
let add_hunk = hunks
.iter()
.find(|h| h.old_text.is_none())
.expect("Add hunk should exist");
let delete_hunk = hunks
.iter()
.find(|h| h.new_text.is_empty())
.expect("Delete hunk should exist");
let add_patch = generate_hunk_patch(baseline, current, add_hunk);
assert!(add_patch.contains("+added line 2a"));
assert!(!add_patch.contains("-line 2"));
let delete_patch = generate_hunk_patch(baseline, current, delete_hunk);
assert!(delete_patch.contains("-line 4"));
assert!(!delete_patch.contains("+line 4"));
}
#[test]
fn test_compute_hunks_after_accept_simulation() {
// Simulate what happens after accepting one hunk and diffing
// This simulates the scenario in test_sequential_accepts_preserve_remaining_hunks
// Patched baseline (after accepting HUNK_A at line 2)
let patched_baseline = "line 1\nHUNK_A\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\n";
// Current content (has all 3 changes)
let current = "line 1\nHUNK_A\nline 3\nline 4\nline 5\nline 6\nHUNK_B\nline 8\nline 9\nline 10\nHUNK_C\nline 12\n";
let hunks = compute_hunks(
Path::new("test.rs"),
patched_baseline,
current,
agent_source(),
);
// Should produce 2 hunks: one at line 7, one at line 11
assert_eq!(
hunks.len(),
2,
"Should produce 2 hunks after accepting first one"
);
// Verify the hunks are at the expected positions
assert_eq!(
hunks[0].line_info.old_start, 7,
"First hunk should be at line 7"
);
assert_eq!(hunks[0].new_text, "HUNK_B\n", "First hunk should be HUNK_B");
assert_eq!(
hunks[1].line_info.old_start, 11,
"Second hunk should be at line 11"
);
assert_eq!(
hunks[1].new_text, "HUNK_C\n",
"Second hunk should be HUNK_C"
);
}
}