drip-cli 0.1.0

Delta Read Interception Proxy — sends only file diffs to your LLM agent
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
//! `drip source-map` CLI tests.
//!
//! Step 4 of the source-map arc: end-to-end coverage that the CLI
//! resolves a compressed line to its original range, prints the full
//! map when no `--line` is given, and degrades gracefully when no map
//! exists (uncompressed read, untracked file).

use crate::common::Drip;
use std::fs;

fn long_python_source() -> String {
    // Five top-level functions, each with a 12-line body. The default
    // `min_body_lines` is 8, so every body gets elided — keeps the
    // assertions stable across DRIP_COMPRESS_MIN_BODY tweaks.
    let mut s = String::from("import os\nimport sys\n\n");
    for n in 0..5 {
        s.push_str(&format!("def fn_{n}(a, b, c):\n"));
        for i in 0..12 {
            s.push_str(&format!("    step_{i:02} = a + b + {i}\n"));
        }
        s.push_str("    return step_11\n\n");
    }
    s
}

#[test]
fn source_map_resolves_a_single_compressed_line_to_original_range() {
    let drip = Drip::new();
    let dir = tempfile::tempdir().unwrap();
    let f = dir.path().join("svc.py");
    fs::write(&f, long_python_source()).unwrap();
    drip.read_stdout(&f);

    // Pull the JSON map first to discover an elided line we can probe
    // — the exact compressed line number depends on stub placement,
    // and hard-coding it would silently break if the compressor's
    // signature/body emission ever shifts by one.
    let json = drip
        .cmd()
        .arg("source-map")
        .arg(&f)
        .arg("--json")
        .output()
        .unwrap();
    assert!(json.status.success());
    let map: serde_json::Value =
        serde_json::from_slice(&json.stdout).expect("--json must produce a JSON array");
    let arr = map.as_array().expect("JSON shape is an array");
    let elided = arr
        .iter()
        .find(|e| e.get("elided").and_then(|v| v.as_bool()) == Some(true))
        .expect("at least one entry must be elided for this fixture");
    let compressed_line = elided.get("compressed_line").unwrap().as_u64().unwrap();
    let want_start = elided.get("original_start").unwrap().as_u64().unwrap();
    let want_end = elided.get("original_end").unwrap().as_u64().unwrap();

    let out = drip
        .cmd()
        .arg("source-map")
        .arg(&f)
        .arg("--line")
        .arg(compressed_line.to_string())
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "{}",
        String::from_utf8_lossy(&out.stderr)
    );
    let s = String::from_utf8_lossy(&out.stdout);
    assert!(
        s.contains(&format!("compressed L{compressed_line}")),
        "missing compressed line label: {s}"
    );
    assert!(
        s.contains(&format!("original L{want_start}-L{want_end}")),
        "missing original-range label: {s}"
    );
    assert!(s.contains("[elided]"), "should mark elided entries: {s}");
}

#[test]
fn source_map_full_table_lists_every_compressed_line() {
    let drip = Drip::new();
    let dir = tempfile::tempdir().unwrap();
    let f = dir.path().join("svc.py");
    fs::write(&f, long_python_source()).unwrap();
    drip.read_stdout(&f);

    let out = drip.cmd().arg("source-map").arg(&f).output().unwrap();
    assert!(
        out.status.success(),
        "{}",
        String::from_utf8_lossy(&out.stderr)
    );
    let s = String::from_utf8_lossy(&out.stdout);
    // Header advertises the entry count + elided regions count.
    assert!(
        s.contains("compressed lines"),
        "missing header summary: {s}"
    );
    assert!(s.contains("elided regions"), "missing elided count: {s}");
    // Every visible signature line should appear as a 1:1 mapping.
    assert!(
        s.contains("→ original L"),
        "rows must use the canonical arrow format: {s}"
    );
}

#[test]
fn source_map_without_compression_emits_clear_no_map_message() {
    // Tiny file → no compression → no source map. The CLI must say so
    // explicitly (with a hint about which recovery path applies)
    // rather than crash or print an empty table.
    let drip = Drip::new();
    let dir = tempfile::tempdir().unwrap();
    let f = dir.path().join("tiny.py");
    fs::write(&f, "def hi():\n    return 1\n").unwrap();
    drip.read_stdout(&f);

    let out = drip.cmd().arg("source-map").arg(&f).output().unwrap();
    assert!(out.status.success());
    let s = String::from_utf8_lossy(&out.stdout);
    assert!(s.contains("No source map"), "expected no-map header: {s}");
    assert!(
        s.contains("no compression fired"),
        "expected reason hint: {s}"
    );
}

#[test]
fn source_map_for_untracked_file_distinguishes_from_uncompressed() {
    // File never read by DRIP — different recovery hint than a read
    // that simply didn't trigger compression.
    let drip = Drip::new();
    let dir = tempfile::tempdir().unwrap();
    let f = dir.path().join("never_read.py");
    fs::write(&f, "x = 1\n").unwrap();

    let out = drip.cmd().arg("source-map").arg(&f).output().unwrap();
    assert!(out.status.success());
    let s = String::from_utf8_lossy(&out.stdout);
    assert!(
        s.contains("no read tracked"),
        "untracked files must surface the read-first hint: {s}"
    );
}

#[test]
fn source_map_accepts_l_prefixed_line_argument() {
    // Stub messages format ranges as `original L5-L21`. Users who
    // copy-paste those into `--line` shouldn't have to strip the
    // leading L by hand.
    let drip = Drip::new();
    let dir = tempfile::tempdir().unwrap();
    let f = dir.path().join("svc.py");
    fs::write(&f, long_python_source()).unwrap();
    drip.read_stdout(&f);

    let plain = drip
        .cmd()
        .arg("source-map")
        .arg(&f)
        .arg("--line")
        .arg("3")
        .output()
        .unwrap();
    let prefixed = drip
        .cmd()
        .arg("source-map")
        .arg(&f)
        .arg("--line")
        .arg("L3")
        .output()
        .unwrap();
    assert!(plain.status.success() && prefixed.status.success());
    assert_eq!(
        String::from_utf8_lossy(&plain.stdout),
        String::from_utf8_lossy(&prefixed.stdout),
        "L-prefix and bare digit should produce identical output"
    );
}

#[test]
fn source_map_rejects_zero_and_garbage_line_args() {
    let drip = Drip::new();
    let dir = tempfile::tempdir().unwrap();
    let f = dir.path().join("svc.py");
    fs::write(&f, long_python_source()).unwrap();
    drip.read_stdout(&f);

    let zero = drip
        .cmd()
        .arg("source-map")
        .arg(&f)
        .arg("--line")
        .arg("0")
        .output()
        .unwrap();
    assert!(!zero.status.success(), "0 should be rejected (1-indexed)");

    let garbage = drip
        .cmd()
        .arg("source-map")
        .arg(&f)
        .arg("--line")
        .arg("nope")
        .output()
        .unwrap();
    assert!(!garbage.status.success(), "non-numeric should be rejected");
}

#[test]
fn drip_refresh_then_reread_regenerates_source_map() {
    // After `drip refresh`, the next read writes a fresh baseline +
    // a fresh source map. Pre-fix would have hit one of two bugs: a
    // stale map (refresh didn't clear the column) or a missing map
    // (refresh nuked the row but the next read didn't rebuild it).
    // Both break the pre-edit guard and `drip source-map --line N`.
    let drip = Drip::new();
    let dir = tempfile::tempdir().unwrap();
    let f = dir.path().join("svc.py");
    fs::write(&f, long_python_source()).unwrap();
    drip.read_stdout(&f);

    // Sanity: map exists.
    let before = drip
        .cmd()
        .arg("source-map")
        .arg(&f)
        .arg("--json")
        .output()
        .unwrap();
    assert!(before.status.success());
    let before_map: serde_json::Value = serde_json::from_slice(&before.stdout).unwrap();
    assert!(!before_map.as_array().unwrap().is_empty());

    // Out-of-band edit: append a brand-new function so the new map
    // gets MORE entries. A same-length edit would leave the map
    // shape identical and the before==after check below couldn't
    // distinguish "refresh re-ran" from "refresh was a no-op".
    let mut mutated = long_python_source();
    mutated.push_str("def fn_extra(a, b, c):\n");
    for i in 0..12 {
        mutated.push_str(&format!("    extra_{i:02} = a + b + {i}\n"));
    }
    mutated.push_str("    return extra_11\n\n");
    fs::write(&f, &mutated).unwrap();

    // Refresh + re-read.
    let r = drip.cmd().arg("refresh").arg(&f).output().unwrap();
    assert!(
        r.status.success(),
        "refresh failed: {}",
        String::from_utf8_lossy(&r.stderr)
    );
    drip.read_stdout(&f);

    let after = drip
        .cmd()
        .arg("source-map")
        .arg(&f)
        .arg("--json")
        .output()
        .unwrap();
    assert!(after.status.success());
    let after_map: serde_json::Value = serde_json::from_slice(&after.stdout).unwrap();
    assert!(
        !after_map.as_array().unwrap().is_empty(),
        "refresh + re-read must regenerate the source map, not leave it empty: {after_map}"
    );
    // The new map must have MORE entries (we appended an extra
    // function). If the maps had the same length, refresh either
    // didn't run, or the read after refresh hit a stale cached
    // baseline.
    let before_len = before_map.as_array().unwrap().len();
    let after_len = after_map.as_array().unwrap().len();
    assert!(
        after_len > before_len,
        "expected more entries after appending a function: before={before_len} after={after_len}"
    );
}

#[test]
fn source_map_line_lookup_handles_out_of_range_compressed_line() {
    // `--line N` with N greater than the largest compressed line
    // must fail soft (exit 0, "unmapped" message), not panic. Also
    // covers --json variant for tooling consumers.
    let drip = Drip::new();
    let dir = tempfile::tempdir().unwrap();
    let f = dir.path().join("svc.py");
    fs::write(&f, long_python_source()).unwrap();
    drip.read_stdout(&f);

    // Pull the actual map size, then probe one past it.
    let json = drip
        .cmd()
        .arg("source-map")
        .arg(&f)
        .arg("--json")
        .output()
        .unwrap();
    let arr: serde_json::Value = serde_json::from_slice(&json.stdout).unwrap();
    let n = arr.as_array().unwrap().len();
    let probe = n + 50;

    let plain = drip
        .cmd()
        .args(["source-map"])
        .arg(&f)
        .arg("--line")
        .arg(probe.to_string())
        .output()
        .unwrap();
    assert!(
        plain.status.success(),
        "must exit 0 on overshoot, not crash"
    );
    let s = String::from_utf8_lossy(&plain.stdout);
    assert!(s.contains("unmapped"), "human form must say unmapped: {s}");

    let j = drip
        .cmd()
        .args(["source-map"])
        .arg(&f)
        .arg("--line")
        .arg(probe.to_string())
        .arg("--json")
        .output()
        .unwrap();
    assert!(j.status.success());
    let parsed: serde_json::Value = serde_json::from_slice(&j.stdout).unwrap();
    assert_eq!(
        parsed.get("unmapped").and_then(|v| v.as_bool()),
        Some(true),
        "JSON form must set unmapped:true: {parsed}"
    );
    assert_eq!(
        parsed.get("compressed_line").and_then(|v| v.as_u64()),
        Some(probe as u64),
        "JSON form must echo the requested line so callers can correlate"
    );
}

#[test]
fn source_map_cli_auto_picks_sibling_session_in_cwd() {
    // Round-3 agent-UX: when an agent in session A compresses
    // `svc.py` and the *user* then types `drip source-map svc.py`
    // in their shell (session B, no reads), the CLI must surface
    // session A's map — not the historical "no read tracked" error.
    //
    // This intentionally inverts the pre-round-3 isolation behavior
    // at the CLI surface. The internal `Session::get_source_map`
    // remains strictly per-(session_id, file_path) — see the
    // `source_map_internal_lookup_is_strictly_session_scoped` test
    // below for the correctness guarantee the pre-edit guard
    // depends on.
    let drip = Drip::new();
    let dir = tempfile::tempdir().unwrap();
    let f = dir.path().join("svc.py");
    fs::write(&f, long_python_source()).unwrap();

    // Seed session A with a compressed read.
    let mut c = drip.cmd();
    c.arg("read").arg(&f).current_dir(dir.path());
    assert!(c.output().unwrap().status.success());

    // Sanity in the seeded session.
    let same = drip
        .cmd()
        .arg("source-map")
        .arg(&f)
        .arg("--json")
        .current_dir(dir.path())
        .output()
        .unwrap();
    let same_arr: serde_json::Value = serde_json::from_slice(&same.stdout).unwrap();
    assert!(!same_arr.as_array().unwrap().is_empty());

    // Sibling session, same cwd → must find the seeded map via the
    // inspect helper's auto-pick (env-strategy + last_active in cwd).
    let other = drip
        .cmd_in_session("source-map-other-session")
        .arg("source-map")
        .arg(&f)
        .current_dir(dir.path())
        .output()
        .unwrap();
    assert!(other.status.success());
    let s = String::from_utf8_lossy(&other.stdout);
    assert!(
        !s.contains("No source map") && !s.contains("no read tracked"),
        "CLI must auto-pick the sibling session in cwd, got: {s}"
    );
    assert!(
        s.contains("source map for") || s.contains("compressed lines"),
        "CLI must render the map content, got: {s}"
    );
}

#[test]
fn source_map_internal_lookup_is_strictly_session_scoped() {
    // Pre-edit guard correctness: even though the CLI auto-picks
    // sibling sessions, the underlying `Session::get_source_map`
    // (called by internal code that already has the right session id)
    // must still answer ONLY for `(self.id, file_path)`. If it ever
    // started leaking across sessions, the pre-edit guard could
    // confirm an Edit against a map from a different agent run.
    //
    // We assert this by checking that a fresh session id pointing at
    // a *different* cwd (so the CLI auto-pick can't find anything)
    // returns "untracked" — confirming the per-session SQL WHERE
    // clause is doing its job. The CLI surface in this scenario is
    // identical to what the pre-edit guard sees internally.
    let drip = Drip::new();
    let seeded_dir = tempfile::tempdir().unwrap();
    let f = seeded_dir.path().join("svc.py");
    fs::write(&f, long_python_source()).unwrap();
    let mut c = drip.cmd();
    c.arg("read").arg(&f).current_dir(seeded_dir.path());
    assert!(c.output().unwrap().status.success());

    // Different cwd — auto-pick has no candidate, falls back to the
    // (empty) derived session, internal lookup returns None.
    let other_dir = tempfile::tempdir().unwrap();
    let other = drip
        .cmd_in_session("strict-isolation-other-session")
        .arg("source-map")
        .arg(&f)
        .current_dir(other_dir.path())
        .output()
        .unwrap();
    assert!(other.status.success());
    let s = String::from_utf8_lossy(&other.stdout);
    assert!(
        s.contains("No source map") && s.contains("no read tracked"),
        "isolated session in a different cwd must NOT see the seeded map: {s}"
    );
}

#[test]
fn source_map_json_round_trips_through_serde() {
    // The JSON output is the de-facto contract for tooling that wants
    // to consume source maps without parsing the human format. Pin it
    // to the persisted column shape.
    let drip = Drip::new();
    let dir = tempfile::tempdir().unwrap();
    let f = dir.path().join("svc.py");
    fs::write(&f, long_python_source()).unwrap();
    drip.read_stdout(&f);

    let out = drip
        .cmd()
        .arg("source-map")
        .arg(&f)
        .arg("--json")
        .output()
        .unwrap();
    assert!(out.status.success());
    let parsed: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
    let arr = parsed.as_array().expect("top level must be an array");
    assert!(!arr.is_empty(), "fixture should yield a populated map");
    for entry in arr {
        assert!(entry.get("compressed_line").is_some());
        assert!(entry.get("original_start").is_some());
        assert!(entry.get("original_end").is_some());
    }
}