Skip to main content

tsift_cli/
rewrite.rs

1use anyhow::{bail, Context as _, Result};
2use std::fs;
3use std::io::{BufRead as _, BufReader, Read as _};
4use std::path::Path;
5use std::process::Command;
6
7use crate::output::OutputFormat;
8use crate::{relativize_pathbuf, shell_split, shell_quote};
9use tsift_agent_doc::session_digest;
10use tsift_graph::lang::Lang;
11use tsift_quality::lint;
12
13#[derive(Clone, Copy)]
14pub(crate) struct OutputCap {
15    pub(crate) max_lines: usize,
16    pub(crate) strip_prefix: Option<&'static str>,
17}
18
19pub(crate) fn execute_rewritten_command(command: &str) -> Result<i32> {
20    let effective_command = effective_rewrite_run_command(command);
21    let parts = shell_split(&effective_command);
22    let Some(program) = parts.first().map(|part| strip_shell_quotes(part)) else {
23        bail!("rewritten command was empty");
24    };
25    let args: Vec<String> = parts[1..]
26        .iter()
27        .map(|part| strip_shell_quotes(part).to_string())
28        .collect();
29    let mut command = if program == "tsift" {
30        Command::new(std::env::current_exe().context("resolving current tsift executable")?)
31    } else {
32        Command::new(program)
33    };
34    let output = command
35        .args(&args)
36        .output()
37        .with_context(|| format!("executing rewritten command `{effective_command}`"))?;
38
39    let stdout = if let Some(cap) = rewrite_output_cap(&effective_command) {
40        apply_output_cap(&output.stdout, cap)
41    } else {
42        String::from_utf8_lossy(&output.stdout).into_owned()
43    };
44    if !stdout.is_empty() {
45        print!("{stdout}");
46    }
47    if !output.stderr.is_empty() {
48        eprint!("{}", String::from_utf8_lossy(&output.stderr));
49    }
50
51    Ok(output
52        .status
53        .code()
54        .unwrap_or_else(|| if output.status.success() { 0 } else { 1 }))
55}
56
57pub(crate) fn effective_rewrite_run_command(command: &str) -> String {
58    let parts = shell_split(command);
59    if parts.first().map(|part| strip_shell_quotes(part)) != Some("tsift") {
60        return command.to_string();
61    }
62    let structured = parts
63        .iter()
64        .skip(1)
65        .any(|part| strip_shell_quotes(part) == "--timeout");
66    let subcommand = parts
67        .iter()
68        .skip(1)
69        .map(|part| strip_shell_quotes(part))
70        .find(|part| !part.starts_with('-'));
71    if matches!(subcommand, Some("search")) && !structured {
72        format!("{command} --timeout 0")
73    } else {
74        command.to_string()
75    }
76}
77
78pub(crate) fn apply_rewrite_output_format(command: &str, format: OutputFormat) -> String {
79    let trimmed = command.trim_start();
80    let Some(rest) = trimmed.strip_prefix("tsift") else {
81        return command.to_string();
82    };
83    let existing_parts = shell_split(rest);
84
85    let mut flags = Vec::new();
86    if format.compact && !rewrite_has_global_flag(&existing_parts, "--compact") {
87        flags.push("--compact");
88    }
89    if format.pretty && !rewrite_has_global_flag(&existing_parts, "--pretty") {
90        flags.push("--pretty");
91    }
92    if format.terse && !rewrite_has_global_flag(&existing_parts, "--terse") {
93        flags.push("--terse");
94    }
95    if format.schema && !rewrite_has_global_flag(&existing_parts, "--schema") {
96        flags.push("--schema");
97    }
98    if format.envelope {
99        if !rewrite_has_global_flag(&existing_parts, "--envelope") {
100            flags.push("--envelope");
101        }
102    } else if format.json_output
103        && !rewrite_has_global_flag(&existing_parts, "--json")
104        && !rewrite_has_global_flag(&existing_parts, "--envelope")
105    {
106        flags.push("--json");
107    }
108
109    if flags.is_empty() {
110        return command.to_string();
111    }
112
113    let forwarded = flags.join(" ");
114    if rest.trim().is_empty() {
115        format!("tsift {forwarded}")
116    } else {
117        format!("tsift {forwarded}{rest}")
118    }
119}
120
121fn rewrite_has_global_flag(parts: &[&str], flag: &str) -> bool {
122    parts
123        .iter()
124        .take_while(|part| {
125            let value = strip_shell_quotes(part);
126            value.starts_with('-') || value == "tsift"
127        })
128        .any(|part| strip_shell_quotes(part) == flag)
129}
130
131pub(crate) fn rewrite_output_cap(command: &str) -> Option<OutputCap> {
132    let parts = shell_split(command);
133    if strip_shell_quotes(parts.first()?) != "tsift" {
134        return None;
135    }
136    let structured = parts.iter().skip(1).any(|part| {
137        matches!(
138            strip_shell_quotes(part),
139            "--json" | "--terse" | "--schema" | "--tabular" | "--envelope"
140        )
141    });
142    if structured {
143        return None;
144    }
145
146    let subcommand = parts
147        .iter()
148        .skip(1)
149        .map(|part| strip_shell_quotes(part))
150        .find(|part| !part.starts_with('-'))?;
151    match subcommand {
152        "communities" => Some(OutputCap {
153            max_lines: 80,
154            strip_prefix: None,
155        }),
156        "explain" => Some(OutputCap {
157            max_lines: 40,
158            strip_prefix: None,
159        }),
160        "graph" => Some(OutputCap {
161            max_lines: 50,
162            strip_prefix: None,
163        }),
164        "index" => Some(OutputCap {
165            max_lines: 30,
166            strip_prefix: None,
167        }),
168        "search" => Some(OutputCap {
169            max_lines: 50,
170            strip_prefix: Some("Strategy:"),
171        }),
172        _ => None,
173    }
174}
175
176pub(crate) fn apply_output_cap(stdout: &[u8], cap: OutputCap) -> String {
177    let cleaned = strip_ansi_codes(&String::from_utf8_lossy(stdout));
178    let mut lines: Vec<String> = cleaned
179        .lines()
180        .map(str::trim_end)
181        .filter(|line| !line.trim().is_empty())
182        .filter(|line| {
183            cap.strip_prefix
184                .map(|prefix| !line.starts_with(prefix))
185                .unwrap_or(true)
186        })
187        .map(ToOwned::to_owned)
188        .collect();
189    if lines.len() > cap.max_lines {
190        let hidden = lines.len() - cap.max_lines;
191        lines.truncate(cap.max_lines);
192        lines.push(format!(
193            "... (+{hidden} more lines; rerun the underlying tsift command directly for the full output)"
194        ));
195    }
196    if lines.is_empty() {
197        String::new()
198    } else {
199        format!("{}\n", lines.join("\n"))
200    }
201}
202
203fn strip_ansi_codes(input: &str) -> String {
204    let mut output = String::with_capacity(input.len());
205    let mut chars = input.chars().peekable();
206    while let Some(ch) = chars.next() {
207        if ch == '\u{1b}' && matches!(chars.peek(), Some('[')) {
208            chars.next();
209            for next in chars.by_ref() {
210                if ('@'..='~').contains(&next) {
211                    break;
212                }
213            }
214            continue;
215        }
216        output.push(ch);
217    }
218    output
219}
220
221/// Attempt to rewrite a shell command to use tsift.
222/// Returns Some(rewritten) if applicable, None if no match.
223///
224/// `pub` (not `pub(crate)`) so the `tsift-sim-world` test-harness crate can
225/// exercise the rewrite surface as a dev-dependency.
226pub fn rewrite_command(command: &str) -> Option<String> {
227    let trimmed = command.trim();
228
229    // Already a tsift command — pass through (exit 0, identical)
230    if trimmed.starts_with("tsift ") || trimmed == "tsift" {
231        return Some(command.to_string());
232    }
233
234    // rg <pattern> [path] [flags] → tsift search "<pattern>" --exact [--path <path>]
235    if let Some(rewritten) = rewrite_rg(trimmed) {
236        return Some(rewritten);
237    }
238
239    // grep -r <pattern> [path] → tsift search "<pattern>" --exact [--path <path>]
240    if let Some(rewritten) = rewrite_grep(trimmed) {
241        return Some(rewritten);
242    }
243
244    // git diff / git show / patch-style history → tsift diff-digest
245    if let Some(rewritten) = rewrite_git_diff(trimmed) {
246        return Some(rewritten);
247    }
248    if let Some(rewritten) = rewrite_git_show(trimmed) {
249        return Some(rewritten);
250    }
251    if let Some(rewritten) = rewrite_git_patch_history(trimmed) {
252        return Some(rewritten);
253    }
254
255    // long session/doc transcript reads → tsift session-digest
256    if let Some(rewritten) = rewrite_session_read_command(trimmed) {
257        return Some(rewritten);
258    }
259
260    // large source-file reads inside indexed repos → tsift source-read windows
261    if let Some(rewritten) = rewrite_source_read_command(trimmed) {
262        return Some(rewritten);
263    }
264
265    // cargo test / pytest → tsift-owned test digest wrapper that preserves exit status
266    if let Some(rewritten) = rewrite_test_command(trimmed) {
267        return Some(rewritten);
268    }
269
270    // verbose build/check/install commands → tsift-owned log digest wrapper
271    if let Some(rewritten) = rewrite_log_command(trimmed) {
272        return Some(rewritten);
273    }
274
275    None
276}
277
278pub(crate) fn no_rewrite_message(command: &str, run: bool) -> String {
279    let trimmed = command.trim();
280    let parts = shell_split(trimmed);
281    let reason = if trimmed.is_empty() {
282        "empty command"
283    } else if has_shell_metacharacters(trimmed) {
284        "shell metacharacters such as pipes, redirection, or background operators are not rewritten"
285    } else if is_file_listing_command(&parts) {
286        "file-listing commands keep original shell/find/rg semantics"
287    } else {
288        "no supported tsift rewrite matched this command"
289    };
290    let action = if run {
291        "`--run` executes only rewritten commands; run the original command directly if intended"
292    } else {
293        "run the original command unchanged"
294    };
295    format!("tsift rewrite: no rewrite: {reason}; {action}")
296}
297
298fn is_file_listing_command(parts: &[&str]) -> bool {
299    match parts.first().copied() {
300        Some("find") => true,
301        Some("rg") => parts
302            .iter()
303            .skip(1)
304            .any(|part| matches!(*part, "--files" | "--type-list")),
305        _ => false,
306    }
307}
308
309/// Rewrite `rg` (ripgrep) commands to tsift search.
310fn rewrite_rg(cmd: &str) -> Option<String> {
311    let parts: Vec<&str> = shell_split(cmd);
312    if parts.is_empty() || parts[0] != "rg" {
313        return None;
314    }
315
316    // File-listing forms do not have a search pattern. Leave them to the
317    // original command so roots, globs, and ignore rules keep rg semantics.
318    if is_file_listing_command(&parts) {
319        return None;
320    }
321
322    // Skip if rg is used with complex flags we can't translate
323    // (pipe chains, output redirection, --replace, --count, etc.)
324    if cmd.contains('|')
325        || cmd.contains('>')
326        || cmd.contains("--replace")
327        || cmd.contains("--count")
328        || cmd.contains("-c")
329        || cmd.contains("--files-with-matches")
330        || cmd.contains("--files-without-match")
331        || cmd.contains("-l")
332    {
333        return None;
334    }
335
336    // Extract the pattern (first non-flag argument after rg)
337    let mut pattern = None;
338    let mut path = None;
339    let mut skip_next = false;
340
341    for part in &parts[1..] {
342        if skip_next {
343            skip_next = false;
344            continue;
345        }
346        // Flags that take a value
347        if matches!(
348            *part,
349            "-t" | "--type"
350                | "-g"
351                | "--glob"
352                | "-A"
353                | "-B"
354                | "-C"
355                | "--max-count"
356                | "--max-depth"
357                | "-m"
358                | "-e"
359        ) {
360            skip_next = true;
361            continue;
362        }
363        // Skip standalone flags
364        if part.starts_with('-') {
365            continue;
366        }
367        // First positional = pattern, second = path
368        if pattern.is_none() {
369            pattern = Some(*part);
370        } else if path.is_none() {
371            path = Some(*part);
372        }
373    }
374
375    Some(build_agent_search_preview_command(pattern?, path))
376}
377
378/// Rewrite `grep -r` commands to tsift search.
379fn rewrite_grep(cmd: &str) -> Option<String> {
380    let parts: Vec<&str> = shell_split(cmd);
381    if parts.is_empty() || parts[0] != "grep" {
382        return None;
383    }
384
385    // Only rewrite recursive grep
386    let has_recursive = parts.iter().any(|p| {
387        *p == "-r"
388            || *p == "-R"
389            || *p == "--recursive"
390            || p.contains('r') && p.starts_with('-') && !p.starts_with("--")
391    });
392    if !has_recursive {
393        return None;
394    }
395
396    // Skip pipe chains
397    if cmd.contains('|') || cmd.contains('>') {
398        return None;
399    }
400
401    let mut pattern = None;
402    let mut path = None;
403    let mut skip_next = false;
404
405    for part in &parts[1..] {
406        if skip_next {
407            skip_next = false;
408            continue;
409        }
410        if matches!(*part, "--include" | "--exclude" | "--exclude-dir" | "-e") {
411            skip_next = true;
412            continue;
413        }
414        if part.starts_with('-') {
415            continue;
416        }
417        if pattern.is_none() {
418            pattern = Some(*part);
419        } else if path.is_none() {
420            path = Some(*part);
421        }
422    }
423
424    Some(build_agent_search_preview_command(pattern?, path))
425}
426
427fn build_agent_search_preview_command(pattern: &str, path: Option<&str>) -> String {
428    let mut result = format!(
429        "tsift --envelope search {} --exact --budget normal",
430        shell_quote(pattern)
431    );
432    if let Some(p) = path {
433        result.push_str(&format!(" --path {}", shell_quote(p)));
434    }
435    result
436}
437
438fn rewrite_git_diff(cmd: &str) -> Option<String> {
439    if has_shell_metacharacters(cmd) {
440        return None;
441    }
442
443    let parts: Vec<&str> = shell_split(cmd);
444    if parts.len() < 2 || parts[0] != "git" || parts[1] != "diff" {
445        return None;
446    }
447    let mut cached = false;
448    let mut path = None;
449    let mut after_double_dash = false;
450
451    for part in &parts[2..] {
452        if after_double_dash {
453            if path.is_none() && !part.starts_with('-') {
454                path = Some(*part);
455                continue;
456            }
457            return None;
458        }
459        match *part {
460            "--cached" | "--staged" => cached = true,
461            "--" => after_double_dash = true,
462            raw if looks_like_path_selector(raw) => {
463                if path.replace(raw).is_some() {
464                    return None;
465                }
466            }
467            _ => return None,
468        }
469    }
470
471    Some(build_diff_digest_command(path.unwrap_or("."), cached, None))
472}
473
474fn rewrite_git_show(cmd: &str) -> Option<String> {
475    if has_shell_metacharacters(cmd) {
476        return None;
477    }
478
479    let parts: Vec<&str> = shell_split(cmd);
480    if parts.len() < 2 || parts[0] != "git" || parts[1] != "show" {
481        return None;
482    }
483
484    let mut revision = "HEAD";
485    let mut path = None;
486    let mut after_double_dash = false;
487
488    for part in &parts[2..] {
489        if after_double_dash {
490            if path.is_none() && !part.starts_with('-') {
491                path = Some(*part);
492                continue;
493            }
494            return None;
495        }
496        match *part {
497            "--" => after_double_dash = true,
498            "-p" | "--patch" | "--stat" => {}
499            raw if raw.starts_with("--format=") => {}
500            raw if !raw.starts_with('-') => {
501                if revision != "HEAD" {
502                    return None;
503                }
504                revision = raw;
505            }
506            _ => return None,
507        }
508    }
509
510    Some(build_diff_digest_command(
511        path.unwrap_or("."),
512        false,
513        Some(revision),
514    ))
515}
516
517fn rewrite_git_patch_history(cmd: &str) -> Option<String> {
518    if has_shell_metacharacters(cmd) {
519        return None;
520    }
521
522    let parts: Vec<&str> = shell_split(cmd);
523    if parts.len() < 2 || parts[0] != "git" || parts[1] != "log" {
524        return None;
525    }
526
527    let mut saw_patch = false;
528    let mut saw_single_commit = false;
529    let mut revision = "HEAD";
530    let mut path = None;
531    let mut after_double_dash = false;
532    let mut skip_next = false;
533
534    for part in &parts[2..] {
535        if skip_next {
536            skip_next = false;
537            if *part == "1" {
538                saw_single_commit = true;
539                continue;
540            }
541            return None;
542        }
543        if after_double_dash {
544            if path.is_none() && !part.starts_with('-') {
545                path = Some(*part);
546                continue;
547            }
548            return None;
549        }
550        match *part {
551            "--" => after_double_dash = true,
552            "-p" | "--patch" => saw_patch = true,
553            "-1" | "-n1" | "--max-count=1" => saw_single_commit = true,
554            "-n" | "--max-count" => skip_next = true,
555            raw if !raw.starts_with('-') => {
556                if revision != "HEAD" {
557                    return None;
558                }
559                revision = raw;
560            }
561            _ => return None,
562        }
563    }
564
565    if !saw_patch || !saw_single_commit {
566        return None;
567    }
568
569    Some(build_diff_digest_command(
570        path.unwrap_or("."),
571        false,
572        Some(revision),
573    ))
574}
575
576fn build_diff_digest_command(path: &str, cached: bool, revision: Option<&str>) -> String {
577    let mut result = "tsift diff-digest".to_string();
578    if cached {
579        result.push_str(" --cached");
580    }
581    if let Some(revision) = revision {
582        result.push_str(&format!(" --revision {}", shell_quote(revision)));
583    }
584    if path == "." {
585        result.push_str(" .");
586    } else {
587        result.push_str(&format!(" {}", shell_quote(path)));
588    }
589    result
590}
591
592const SESSION_READ_LINE_THRESHOLD: usize = 80;
593const SOURCE_READ_LINE_THRESHOLD: usize = 80;
594
595#[derive(Debug, Clone, Copy, PartialEq, Eq)]
596enum FileReadWindow {
597    FullFile,
598    FromStart { lines: usize },
599    FromEnd { lines: usize },
600    Range { start: usize, lines: usize },
601}
602
603struct FileReadTarget {
604    input: String,
605    requested_lines: Option<usize>,
606    window: FileReadWindow,
607}
608
609fn rewrite_session_read_command(cmd: &str) -> Option<String> {
610    if has_shell_metacharacters(cmd) {
611        return None;
612    }
613
614    let target = parse_file_read_target(cmd)?;
615    let input_path = Path::new(&target.input);
616    let source = detect_session_digest_source(input_path)?;
617
618    if let Some(requested_lines) = target.requested_lines {
619        if requested_lines < SESSION_READ_LINE_THRESHOLD {
620            return None;
621        }
622    } else if !file_has_at_least_lines(input_path, SESSION_READ_LINE_THRESHOLD) {
623        return None;
624    }
625
626    let digest_path = resolve_digest_context_path(input_path);
627    Some(build_session_digest_command(
628        &digest_path,
629        &target.input,
630        source,
631    ))
632}
633
634fn rewrite_source_read_command(cmd: &str) -> Option<String> {
635    if has_shell_metacharacters(cmd) {
636        return None;
637    }
638
639    let target = parse_file_read_target(cmd)?;
640    let input_path = Path::new(&target.input);
641    if !file_is_supported_source(input_path) {
642        return None;
643    }
644
645    if let Some(requested_lines) = target.requested_lines {
646        if requested_lines < SOURCE_READ_LINE_THRESHOLD {
647            return None;
648        }
649    } else if !file_has_at_least_lines(input_path, SOURCE_READ_LINE_THRESHOLD) {
650        return None;
651    }
652
653    let root = lint::find_project_root_for_path(input_path).ok()??;
654    if !project_has_index(&root) {
655        return None;
656    }
657    let file_abs = input_path.canonicalize().ok()?;
658    let file_display = relativize_pathbuf(&file_abs, &root)
659        .to_string_lossy()
660        .to_string();
661    let total_lines = count_file_lines(&file_abs)?;
662    let (start, lines) = source_window_for_read(target.window, total_lines)?;
663    Some(build_source_read_rewrite_command(
664        &root,
665        &file_display,
666        start,
667        lines,
668    ))
669}
670
671fn parse_file_read_target(cmd: &str) -> Option<FileReadTarget> {
672    let parts: Vec<&str> = shell_split(cmd);
673    let head = parts.first().copied()?;
674    match head {
675        "cat" | "bat" | "batcat" => parse_cat_like_read_target(&parts),
676        "head" | "tail" => parse_head_tail_read_target(&parts),
677        "sed" => parse_sed_read_target(&parts),
678        _ => None,
679    }
680}
681
682fn parse_cat_like_read_target(parts: &[&str]) -> Option<FileReadTarget> {
683    let mut input = None;
684    for part in &parts[1..] {
685        if part.starts_with('-') {
686            continue;
687        }
688        if input.replace(strip_shell_quotes(part)).is_some() {
689            return None;
690        }
691    }
692    Some(FileReadTarget {
693        input: input?.to_string(),
694        requested_lines: None,
695        window: FileReadWindow::FullFile,
696    })
697}
698
699fn parse_head_tail_read_target(parts: &[&str]) -> Option<FileReadTarget> {
700    let mut requested_lines = 10;
701    let mut input = None;
702    let mut index = 1;
703
704    while index < parts.len() {
705        let part = parts[index];
706        if part == "-n" || part == "--lines" {
707            index += 1;
708            requested_lines = parse_requested_line_count(parts.get(index).copied()?)?;
709            index += 1;
710            continue;
711        }
712        if let Some(raw) = part.strip_prefix("-n")
713            && !raw.is_empty()
714        {
715            requested_lines = parse_requested_line_count(raw)?;
716            index += 1;
717            continue;
718        }
719        if let Some(raw) = part.strip_prefix("--lines=") {
720            requested_lines = parse_requested_line_count(raw)?;
721            index += 1;
722            continue;
723        }
724        if part.starts_with('-') && part[1..].chars().all(|ch| ch.is_ascii_digit()) {
725            requested_lines = parse_requested_line_count(&part[1..])?;
726            index += 1;
727            continue;
728        }
729        if input.replace(strip_shell_quotes(part)).is_some() {
730            return None;
731        }
732        index += 1;
733    }
734
735    let window = match parts[0] {
736        "head" => FileReadWindow::FromStart {
737            lines: requested_lines,
738        },
739        "tail" => FileReadWindow::FromEnd {
740            lines: requested_lines,
741        },
742        _ => return None,
743    };
744
745    Some(FileReadTarget {
746        input: input?.to_string(),
747        requested_lines: Some(requested_lines),
748        window,
749    })
750}
751
752fn parse_sed_read_target(parts: &[&str]) -> Option<FileReadTarget> {
753    if parts.len() != 4 || parts[1] != "-n" {
754        return None;
755    }
756
757    let (start, lines) = parse_sed_print_window(parts[2])?;
758    Some(FileReadTarget {
759        input: strip_shell_quotes(parts[3]).to_string(),
760        requested_lines: Some(lines),
761        window: FileReadWindow::Range { start, lines },
762    })
763}
764
765fn parse_requested_line_count(raw: &str) -> Option<usize> {
766    let trimmed = strip_shell_quotes(raw);
767    if let Some(number) = trimmed.strip_prefix('+') {
768        number.parse::<usize>().ok()?;
769        return Some(SESSION_READ_LINE_THRESHOLD);
770    }
771    trimmed.parse::<usize>().ok()
772}
773
774fn parse_sed_print_window(raw: &str) -> Option<(usize, usize)> {
775    let trimmed = strip_shell_quotes(raw);
776    let range = trimmed.strip_suffix('p')?;
777    let (start, end) = range.split_once(',')?;
778    let start = start.parse::<usize>().ok()?;
779    let end = end.parse::<usize>().ok()?;
780    (end >= start).then_some((start, end - start + 1))
781}
782
783fn file_is_supported_source(path: &Path) -> bool {
784    path.extension()
785        .and_then(|ext| ext.to_str())
786        .and_then(Lang::from_extension)
787        .is_some()
788}
789
790fn count_file_lines(path: &Path) -> Option<usize> {
791    let file = fs::File::open(path).ok()?;
792    Some(
793        BufReader::new(file)
794            .lines()
795            .filter(|line| line.is_ok())
796            .count(),
797    )
798}
799
800fn source_window_for_read(window: FileReadWindow, total_lines: usize) -> Option<(usize, usize)> {
801    if total_lines == 0 {
802        return None;
803    }
804    match window {
805        FileReadWindow::FullFile => Some((1, SOURCE_READ_LINE_THRESHOLD.min(total_lines))),
806        FileReadWindow::FromStart { lines } => Some((1, lines.min(total_lines))),
807        FileReadWindow::FromEnd { lines } => {
808            let bounded = lines.min(total_lines);
809            Some((total_lines - bounded + 1, bounded))
810        }
811        FileReadWindow::Range { start, lines } => {
812            if start == 0 || start > total_lines {
813                return None;
814            }
815            Some((start, lines.min(total_lines - start + 1)))
816        }
817    }
818}
819
820fn build_source_read_rewrite_command(
821    root: &Path,
822    file: &str,
823    start: usize,
824    lines: usize,
825) -> String {
826    format!(
827        "tsift --envelope source-read {} --path {} --start {} --lines {} --budget normal",
828        shell_quote(file),
829        shell_quote(&root.to_string_lossy()),
830        start,
831        lines
832    )
833}
834
835fn project_has_index(root: &Path) -> bool {
836    let tsift_dir = root.join(".tsift");
837    tsift_dir.join("index.db").is_file() || directory_contains_index_db(&tsift_dir.join("indexes"))
838}
839
840fn directory_contains_index_db(path: &Path) -> bool {
841    let Ok(entries) = fs::read_dir(path) else {
842        return false;
843    };
844    for entry in entries.flatten() {
845        let path = entry.path();
846        if path.file_name().is_some_and(|name| name == "index.db") && path.is_file() {
847            return true;
848        }
849        if path.is_dir() && directory_contains_index_db(&path) {
850            return true;
851        }
852    }
853    false
854}
855
856fn detect_session_digest_source(path: &Path) -> Option<session_digest::SessionDigestSource> {
857    match path.extension().and_then(|ext| ext.to_str()) {
858        Some("md") if file_looks_like_agent_doc_session(path) => {
859            Some(session_digest::SessionDigestSource::Markdown)
860        }
861        Some("jsonl") if file_looks_like_claude_jsonl(path) => {
862            Some(session_digest::SessionDigestSource::ClaudeJsonl)
863        }
864        Some("jsonl") if file_looks_like_codex_jsonl(path) => {
865            Some(session_digest::SessionDigestSource::CodexJsonl)
866        }
867        Some("log") if file_looks_like_agent_doc_log(path) => {
868            Some(session_digest::SessionDigestSource::AgentDocLog)
869        }
870        _ => None,
871    }
872}
873
874fn file_looks_like_agent_doc_session(path: &Path) -> bool {
875    let prefix = match read_file_prefix(path, 16 * 1024) {
876        Some(prefix) => prefix,
877        None => return false,
878    };
879    prefix.contains("agent_doc_session:")
880        || prefix.contains("<!-- agent:exchange")
881        || prefix.contains("\n## Exchange")
882}
883
884fn file_looks_like_claude_jsonl(path: &Path) -> bool {
885    let prefix = match read_file_prefix(path, 16 * 1024) {
886        Some(prefix) => prefix,
887        None => return false,
888    };
889
890    prefix
891        .lines()
892        .map(str::trim)
893        .filter(|line| !line.is_empty())
894        .take(3)
895        .any(|line| {
896            let value = match serde_json::from_str::<serde_json::Value>(line) {
897                Ok(value) => value,
898                Err(_) => return false,
899            };
900            value.get("message").is_some()
901                || value.get("role").is_some()
902                || value.get("content").is_some()
903        })
904}
905
906fn file_looks_like_codex_jsonl(path: &Path) -> bool {
907    let prefix = match read_file_prefix(path, 16 * 1024) {
908        Some(prefix) => prefix,
909        None => return false,
910    };
911
912    prefix
913        .lines()
914        .map(str::trim)
915        .filter(|line| !line.is_empty())
916        .take(8)
917        .any(|line| {
918            let value = match serde_json::from_str::<serde_json::Value>(line) {
919                Ok(value) => value,
920                Err(_) => return false,
921            };
922            matches!(
923                value.get("type").and_then(serde_json::Value::as_str),
924                Some("session_meta" | "response_item" | "event_msg")
925            )
926        })
927}
928
929fn file_looks_like_agent_doc_log(path: &Path) -> bool {
930    let prefix = match read_file_prefix(path, 16 * 1024) {
931        Some(prefix) => prefix,
932        None => return false,
933    };
934    prefix
935        .lines()
936        .map(str::trim)
937        .filter(|line| !line.is_empty())
938        .take(8)
939        .all(|line| line.starts_with('[') && line.contains("] "))
940}
941
942fn read_file_prefix(path: &Path, max_bytes: usize) -> Option<String> {
943    let file = fs::File::open(path).ok()?;
944    let mut reader = BufReader::new(file);
945    let mut buffer = Vec::new();
946    reader
947        .by_ref()
948        .take(max_bytes as u64)
949        .read_to_end(&mut buffer)
950        .ok()?;
951    Some(String::from_utf8_lossy(&buffer).into_owned())
952}
953
954fn file_has_at_least_lines(path: &Path, min_lines: usize) -> bool {
955    let file = match fs::File::open(path) {
956        Ok(file) => file,
957        Err(_) => return false,
958    };
959    let reader = BufReader::new(file);
960    reader
961        .lines()
962        .take(min_lines)
963        .filter(|line| line.is_ok())
964        .count()
965        >= min_lines
966}
967
968fn build_session_digest_command(
969    path: &str,
970    input: &str,
971    source: session_digest::SessionDigestSource,
972) -> String {
973    format!(
974        "tsift session-digest --path {} --input {} --source {}",
975        shell_quote(path),
976        shell_quote(input),
977        source.cli_arg()
978    )
979}
980
981pub(crate) fn resolve_digest_context_path(path: &Path) -> String {
982    lint::resolve_harness_root_or_canonical_path(path)
983        .map(|root| root.display().to_string())
984        .unwrap_or_else(|_| ".".to_string())
985}
986
987fn rewrite_test_command(cmd: &str) -> Option<String> {
988    if has_shell_metacharacters(cmd) {
989        return None;
990    }
991
992    let parts: Vec<&str> = shell_split(cmd);
993    if parts.len() >= 2 && parts[0] == "cargo" && parts[1] == "test" {
994        return Some(build_digest_runner_command("test", ".", Some("cargo"), cmd));
995    }
996    if !parts.is_empty() && parts[0] == "pytest" {
997        return Some(build_digest_runner_command(
998            "test",
999            ".",
1000            Some("pytest"),
1001            cmd,
1002        ));
1003    }
1004    if parts.len() >= 3 && parts[0] == "python" && parts[1] == "-m" && parts[2] == "pytest" {
1005        return Some(build_digest_runner_command(
1006            "test",
1007            ".",
1008            Some("pytest"),
1009            cmd,
1010        ));
1011    }
1012    None
1013}
1014
1015fn rewrite_log_command(cmd: &str) -> Option<String> {
1016    if has_shell_metacharacters(cmd) {
1017        return None;
1018    }
1019
1020    let parts: Vec<&str> = shell_split(cmd);
1021    if parts.len() >= 2
1022        && parts[0] == "cargo"
1023        && matches!(parts[1], "build" | "check" | "clippy" | "install")
1024    {
1025        return Some(build_digest_runner_command("log", ".", None, cmd));
1026    }
1027    None
1028}
1029
1030fn build_digest_runner_command(
1031    kind: &str,
1032    path: &str,
1033    runner: Option<&str>,
1034    shell_command: &str,
1035) -> String {
1036    let mut result = format!(
1037        "tsift --envelope digest-runner --kind {} --path {} --shell-command {}",
1038        shell_quote(kind),
1039        shell_quote(path),
1040        shell_quote(shell_command)
1041    );
1042    if let Some(runner) = runner {
1043        result.push_str(&format!(" --runner {}", shell_quote(runner)));
1044    }
1045    result
1046}
1047
1048fn has_shell_metacharacters(cmd: &str) -> bool {
1049    cmd.contains('|') || cmd.contains('>') || cmd.contains('<') || cmd.contains('&')
1050}
1051
1052fn strip_shell_quotes(s: &str) -> &str {
1053    if s.len() >= 2
1054        && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
1055    {
1056        &s[1..s.len() - 1]
1057    } else {
1058        s
1059    }
1060}
1061
1062fn looks_like_path_selector(raw: &str) -> bool {
1063    raw.ends_with('/')
1064        || raw.starts_with("./")
1065        || raw.starts_with("../")
1066        || raw.contains('/')
1067        || raw.contains('.')
1068}