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
221pub fn rewrite_command(command: &str) -> Option<String> {
227 let trimmed = command.trim();
228
229 if trimmed.starts_with("tsift ") || trimmed == "tsift" {
231 return Some(command.to_string());
232 }
233
234 if let Some(rewritten) = rewrite_rg(trimmed) {
236 return Some(rewritten);
237 }
238
239 if let Some(rewritten) = rewrite_grep(trimmed) {
241 return Some(rewritten);
242 }
243
244 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 if let Some(rewritten) = rewrite_session_read_command(trimmed) {
257 return Some(rewritten);
258 }
259
260 if let Some(rewritten) = rewrite_source_read_command(trimmed) {
262 return Some(rewritten);
263 }
264
265 if let Some(rewritten) = rewrite_test_command(trimmed) {
267 return Some(rewritten);
268 }
269
270 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
309fn 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 if is_file_listing_command(&parts) {
319 return None;
320 }
321
322 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 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 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 if part.starts_with('-') {
365 continue;
366 }
367 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
378fn 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 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 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}