返回 CodeWhale
eval.rs
根目录 / crates / tui / src / eval.rs
1 //! Offline evaluation harness for exercising representative tool loops.
2 //!
3 //! This module is intentionally self-contained so it can be wired into a CLI
4 //! command later without calling the network or any LLM endpoints.
5
6 use anyhow::{Context, Result, anyhow};
7 use ignore::WalkBuilder;
8 use regex::Regex;
9 use serde::{Deserialize, Serialize};
10 use std::collections::BTreeMap;
11 use std::fs;
12 use std::io::Write;
13 use std::path::{Path, PathBuf};
14 use std::time::{Duration, Instant};
15 use tempfile::TempDir;
16
17 #[cfg(test)]
18 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
19 enum EvalShellPlatform {
20 Windows,
21 Unix,
22 }
23
24 #[cfg(test)]
25 #[derive(Debug, Clone, PartialEq, Eq)]
26 struct EvalShellInvocation {
27 program: &'static str,
28 args: Vec<String>,
29 raw_payload_on_windows: bool,
30 }
31
32 #[cfg(test)]
33 fn eval_shell_invocation_for_platform(
34 command: &str,
35 platform: EvalShellPlatform,
36 ) -> EvalShellInvocation {
37 match platform {
38 EvalShellPlatform::Windows => EvalShellInvocation {
39 program: "cmd",
40 args: vec!["/C".to_string(), command.to_string()],
41 raw_payload_on_windows: true,
42 },
43 EvalShellPlatform::Unix => EvalShellInvocation {
44 program: "sh",
45 args: vec!["-c".to_string(), command.to_string()],
46 raw_payload_on_windows: false,
47 },
48 }
49 }
50
51 /// Representative tool steps covered by the evaluation harness.
52 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
53 pub enum ScenarioStepKind {
54 List,
55 Read,
56 Search,
57 Edit,
58 ApplyPatch,
59 Bash,
60 }
61
62 impl ScenarioStepKind {
63 /// Canonical tool name associated with this step.
64 pub fn tool_name(self) -> &'static str {
65 match self {
66 ScenarioStepKind::List
67 | ScenarioStepKind::Read
68 | ScenarioStepKind::Search
69 | ScenarioStepKind::Edit => "File",
70 ScenarioStepKind::ApplyPatch => "apply_patch",
71 ScenarioStepKind::Bash => "Bash",
72 }
73 }
74
75 /// Canonical action for action-based tools.
76 pub fn action(self) -> Option<&'static str> {
77 match self {
78 ScenarioStepKind::List => Some("list"),
79 ScenarioStepKind::Read => Some("read"),
80 ScenarioStepKind::Search => Some("search_content"),
81 ScenarioStepKind::Edit => Some("edit"),
82 ScenarioStepKind::ApplyPatch => None,
83 ScenarioStepKind::Bash => Some("run"),
84 }
85 }
86
87 /// Parse a step kind from CLI-friendly strings.
88 pub fn parse(value: &str) -> Option<Self> {
89 match value.trim().to_lowercase().as_str() {
90 "list" => Some(Self::List),
91 "read" => Some(Self::Read),
92 "search" | "grep" => Some(Self::Search),
93 "edit" => Some(Self::Edit),
94 "patch" | "apply_patch" => Some(Self::ApplyPatch),
95 "bash" | "shell" | "exec" => Some(Self::Bash),
96 _ => None,
97 }
98 }
99 }
100
101 /// Aggregate statistics for a single tool kind.
102 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
103 pub struct ToolStats {
104 pub invocations: usize,
105 pub errors: usize,
106 pub total_duration: Duration,
107 }
108
109 /// Top-level metrics produced by an evaluation run.
110 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
111 pub struct EvalMetrics {
112 pub success: bool,
113 pub tool_errors: usize,
114 pub steps: usize,
115 pub duration: Duration,
116 pub per_tool: BTreeMap<ScenarioStepKind, ToolStats>,
117 }
118
119 /// One tool invocation recorded by the harness.
120 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
121 pub struct EvalStep {
122 pub kind: ScenarioStepKind,
123 pub tool_name: &'static str,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub action: Option<&'static str>,
126 pub success: bool,
127 pub duration: Duration,
128 pub error: Option<String>,
129 pub output: Option<String>,
130 }
131
132 /// Summary of the generated temporary workspace.
133 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
134 pub struct WorkspaceSummary {
135 pub root: PathBuf,
136 pub file_count: usize,
137 pub files: Vec<PathBuf>,
138 }
139
140 /// Configuration for the offline evaluation harness.
141 #[derive(Debug, Clone)]
142 pub struct EvalHarnessConfig {
143 /// Human-readable scenario name for reporting.
144 pub scenario_name: String,
145 /// If set, the harness will intentionally fail this step to test metrics.
146 pub fail_step: Option<ScenarioStepKind>,
147 /// Shell command executed during the canonical `Bash.run` step.
148 pub shell_command: String,
149 /// Token that must appear in shell output for validation.
150 pub shell_expect_token: String,
151 /// Maximum characters stored for step output summaries.
152 pub max_output_chars: usize,
153 /// When set, every step is appended as a JSON Lines fixture to a file
154 /// inside this directory. The fixture file is named after the scenario
155 /// (e.g. `offline-tool-loop.jsonl`). Each line follows the schema:
156 /// `{ "request": <step descriptor>, "response_events": [<events>] }`.
157 /// The mock LLM client (`crate::llm_client::mock`) can replay these
158 /// fixtures for deterministic offline tests. See
159 /// `crates/tui/tests/README.md` for the full record/replay flow.
160 pub record_dir: Option<PathBuf>,
161 }
162
163 impl Default for EvalHarnessConfig {
164 fn default() -> Self {
165 let shell_command = if cfg!(windows) {
166 "echo eval-harness".to_string()
167 } else {
168 "printf eval-harness".to_string()
169 };
170 Self {
171 scenario_name: "offline-tool-loop".to_string(),
172 fail_step: None,
173 shell_command,
174 shell_expect_token: "eval-harness".to_string(),
175 max_output_chars: 240,
176 record_dir: None,
177 }
178 }
179 }
180
181 /// Offline harness that exercises representative tool loops in a temp workspace.
182 #[derive(Debug, Clone)]
183 pub struct EvalHarness {
184 config: EvalHarnessConfig,
185 }
186
187 impl EvalHarness {
188 /// Create a new harness with the provided configuration.
189 pub fn new(config: EvalHarnessConfig) -> Self {
190 Self { config }
191 }
192
193 /// Execute the offline evaluation scenario and return detailed results.
194 pub fn run(&self) -> Result<EvalRun> {
195 let started_at = Instant::now();
196 let workspace = tempfile::Builder::new()
197 .prefix("deepseek-eval-")
198 .tempdir()
199 .context("failed to create evaluation workspace")?;
200
201 let seed = seed_workspace(workspace.path())?;
202
203 let mut steps = Vec::new();
204 let mut per_tool: BTreeMap<ScenarioStepKind, ToolStats> = BTreeMap::new();
205
206 let list_output = self.run_step(ScenarioStepKind::List, &mut steps, &mut per_tool, || {
207 let entries = list_workspace(workspace.path())?;
208 Ok(entries.join(", "))
209 });
210
211 let _read_output = self.run_step(ScenarioStepKind::Read, &mut steps, &mut per_tool, || {
212 let path = if self.config.fail_step == Some(ScenarioStepKind::Read) {
213 workspace.path().join("missing.txt")
214 } else {
215 seed.notes_path.clone()
216 };
217 read_workspace_file(&path)
218 });
219
220 let search_output =
221 self.run_step(ScenarioStepKind::Search, &mut steps, &mut per_tool, || {
222 let root = if self.config.fail_step == Some(ScenarioStepKind::Search) {
223 workspace.path().join("missing-dir")
224 } else {
225 workspace.path().to_path_buf()
226 };
227 let result = search_files(&root, "offline")?;
228 Ok(format!("matches={}", result.matches.len()))
229 });
230
231 let edit_output = self.run_step(ScenarioStepKind::Edit, &mut steps, &mut per_tool, || {
232 let path = if self.config.fail_step == Some(ScenarioStepKind::Edit) {
233 workspace.path().join("missing.txt")
234 } else {
235 seed.notes_path.clone()
236 };
237 append_workspace_file(&path, "edited = true")?;
238 Ok("appended line".to_string())
239 });
240
241 let patch_output = self.run_step(
242 ScenarioStepKind::ApplyPatch,
243 &mut steps,
244 &mut per_tool,
245 || {
246 let patch = if self.config.fail_step == Some(ScenarioStepKind::ApplyPatch) {
247 "*** Begin Patch\n*** Update File: notes.txt\n@@\n-THIS LINE DOES NOT EXIST\n+broken\n*** End Patch\n"
248 .to_string()
249 } else {
250 "*** Begin Patch\n*** Update File: notes.txt\n@@\n status = \"draft\"\n-todo: offline metrics\n+todo: offline metrics (patched)\n*** End Patch\n"
251 .to_string()
252 };
253 apply_patch(workspace.path(), &patch)?;
254 Ok("patch applied".to_string())
255 },
256 );
257
258 let shell_output = self.run_step(ScenarioStepKind::Bash, &mut steps, &mut per_tool, || {
259 let command = if self.config.fail_step == Some(ScenarioStepKind::Bash) {
260 "command_that_does_not_exist".to_string()
261 } else {
262 self.config.shell_command.clone()
263 };
264 run_bash(workspace.path(), &command)
265 });
266
267 let duration = started_at.elapsed();
268
269 let workspace_summary = summarize_workspace(workspace.path(), list_output.as_deref())?;
270
271 let validation_success = validate_outputs(
272 workspace.path(),
273 &self.config.shell_expect_token,
274 search_output.as_deref(),
275 edit_output.as_deref(),
276 patch_output.as_deref(),
277 shell_output.as_deref(),
278 );
279
280 let tool_errors = steps.iter().filter(|s| !s.success).count();
281 let success = tool_errors == 0 && validation_success;
282
283 let metrics = EvalMetrics {
284 success,
285 tool_errors,
286 steps: steps.len(),
287 duration,
288 per_tool,
289 };
290
291 Ok(EvalRun {
292 scenario_name: self.config.scenario_name.clone(),
293 workspace,
294 workspace_summary,
295 metrics,
296 steps,
297 })
298 }
299
300 fn run_step<T, F>(
301 &self,
302 kind: ScenarioStepKind,
303 steps: &mut Vec<EvalStep>,
304 per_tool: &mut BTreeMap<ScenarioStepKind, ToolStats>,
305 f: F,
306 ) -> Option<T>
307 where
308 F: FnOnce() -> Result<T>,
309 T: ToString,
310 {
311 let started_at = Instant::now();
312 let result = f();
313 let duration = started_at.elapsed();
314
315 let stats = per_tool.entry(kind).or_default();
316 stats.invocations += 1;
317 stats.total_duration += duration;
318
319 match result {
320 Ok(value) => {
321 let output = truncate_output(&value.to_string(), self.config.max_output_chars);
322 steps.push(EvalStep {
323 kind,
324 tool_name: kind.tool_name(),
325 action: kind.action(),
326 success: true,
327 duration,
328 error: None,
329 output: Some(output.clone()),
330 });
331 if let Some(dir) = self.config.record_dir.as_deref() {
332 let _ = record_fixture(
333 dir,
334 &self.config.scenario_name,
335 FixtureRecord::ok(kind, &output),
336 );
337 }
338 Some(value)
339 }
340 Err(err) => {
341 stats.errors += 1;
342 let err_str = err.to_string();
343 steps.push(EvalStep {
344 kind,
345 tool_name: kind.tool_name(),
346 action: kind.action(),
347 success: false,
348 duration,
349 error: Some(err_str.clone()),
350 output: None,
351 });
352 if let Some(dir) = self.config.record_dir.as_deref() {
353 let _ = record_fixture(
354 dir,
355 &self.config.scenario_name,
356 FixtureRecord::err(kind, &err_str),
357 );
358 }
359 None
360 }
361 }
362 }
363 }
364
365 // === Fixture record/replay format ===========================================
366 //
367 // The `--record` flag writes one JSON object per line to a `.jsonl` file:
368 //
369 // { "request": { "tool": "File", "action": "list", "kind": "List" },
370 // "response_events": [{ "type": "ok", "output": "…" }] }
371 //
372 // The mock LLM client replays these fixtures via
373 // `MockLlmClient::push_message_response` (or the streaming variant) by mapping
374 // each `response_events` array onto a canned `Vec<StreamEvent>`.
375 //
376 // This format is intentionally minimal — additional fields (timing, model,
377 // usage) can be added without breaking older fixtures because each line is a
378 // self-contained JSON object.
379
380 /// Schema for one line of a `--record` JSONL fixture file.
381 #[derive(Debug, Clone, Serialize, Deserialize)]
382 pub struct FixtureRecord {
383 /// Step descriptor (`{ step, kind }`).
384 pub request: serde_json::Value,
385 /// One or more synthetic response events.
386 pub response_events: Vec<serde_json::Value>,
387 }
388
389 impl FixtureRecord {
390 fn ok(kind: ScenarioStepKind, output: &str) -> Self {
391 Self {
392 request: Self::request(kind),
393 response_events: vec![serde_json::json!({
394 "type": "ok",
395 "output": output,
396 })],
397 }
398 }
399
400 fn err(kind: ScenarioStepKind, error: &str) -> Self {
401 Self {
402 request: Self::request(kind),
403 response_events: vec![serde_json::json!({
404 "type": "error",
405 "error": error,
406 })],
407 }
408 }
409
410 fn request(kind: ScenarioStepKind) -> serde_json::Value {
411 let mut request = serde_json::json!({
412 "tool": kind.tool_name(),
413 "kind": format!("{kind:?}"),
414 });
415 if let Some(action) = kind.action() {
416 request["action"] = serde_json::Value::String(action.to_string());
417 }
418 request
419 }
420 }
421
422 /// Append one fixture record to `<dir>/<scenario>.jsonl` (creating dir + file
423 /// if missing). Best-effort: I/O errors are returned but generally ignored by
424 /// the harness so a recording failure does not mask the run's primary result.
425 pub fn record_fixture(dir: &Path, scenario_name: &str, record: FixtureRecord) -> Result<PathBuf> {
426 fs::create_dir_all(dir)
427 .with_context(|| format!("failed to create fixture dir: {}", dir.display()))?;
428 let safe_scenario = scenario_name
429 .chars()
430 .map(|c| {
431 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
432 c
433 } else {
434 '_'
435 }
436 })
437 .collect::<String>();
438 let path = dir.join(format!("{safe_scenario}.jsonl"));
439 let line = serde_json::to_string(&record).context("failed to serialize fixture record")?;
440
441 let mut file = fs::OpenOptions::new()
442 .create(true)
443 .append(true)
444 .open(&path)
445 .with_context(|| format!("failed to open fixture file: {}", path.display()))?;
446 writeln!(file, "{line}")
447 .with_context(|| format!("failed to write fixture line to {}", path.display()))?;
448 Ok(path)
449 }
450
451 impl Default for EvalHarness {
452 fn default() -> Self {
453 Self::new(EvalHarnessConfig::default())
454 }
455 }
456
457 /// Result of running the evaluation harness.
458 #[derive(Debug)]
459 pub struct EvalRun {
460 pub scenario_name: String,
461 workspace: TempDir,
462 pub workspace_summary: WorkspaceSummary,
463 pub metrics: EvalMetrics,
464 pub steps: Vec<EvalStep>,
465 }
466
467 impl EvalRun {
468 /// Get the root of the temporary workspace.
469 pub fn workspace_root(&self) -> &Path {
470 self.workspace.path()
471 }
472
473 /// Convert the run into a serializable report for CLI output.
474 pub fn to_report(&self) -> EvalReport {
475 EvalReport {
476 scenario_name: self.scenario_name.clone(),
477 workspace_root: self.workspace_root().to_path_buf(),
478 workspace_summary: self.workspace_summary.clone(),
479 metrics: self.metrics.clone(),
480 steps: self.steps.clone(),
481 }
482 }
483 }
484
485 /// Serializable report derived from an `EvalRun`.
486 #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
487 pub struct EvalReport {
488 pub scenario_name: String,
489 pub workspace_root: PathBuf,
490 pub workspace_summary: WorkspaceSummary,
491 pub metrics: EvalMetrics,
492 pub steps: Vec<EvalStep>,
493 }
494
495 #[derive(Debug, Clone)]
496 struct SeedWorkspace {
497 notes_path: PathBuf,
498 }
499
500 fn seed_workspace(root: &Path) -> Result<SeedWorkspace> {
501 let src_dir = root.join("src");
502 fs::create_dir_all(&src_dir)
503 .with_context(|| format!("failed to create seed directory: {}", src_dir.display()))?;
504
505 let readme_path = root.join("README.md");
506 fs::write(
507 &readme_path,
508 "# Eval Harness Workspace\n\nThis workspace is offline.\n",
509 )
510 .with_context(|| format!("failed to write {}", readme_path.display()))?;
511
512 let notes_path = root.join("notes.txt");
513 fs::write(
514 &notes_path,
515 "# Eval Harness\nstatus = \"draft\"\ntodo: offline metrics\n",
516 )
517 .with_context(|| format!("failed to write {}", notes_path.display()))?;
518
519 let lib_path = src_dir.join("lib.rs");
520 fs::write(
521 &lib_path,
522 "pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n",
523 )
524 .with_context(|| format!("failed to write {}", lib_path.display()))?;
525
526 Ok(SeedWorkspace { notes_path })
527 }
528
529 fn summarize_workspace(root: &Path, list_output: Option<&str>) -> Result<WorkspaceSummary> {
530 let mut files = Vec::new();
531
532 let walker = WalkBuilder::new(root)
533 .hidden(false)
534 .git_ignore(false)
535 .git_global(false)
536 .git_exclude(false)
537 .build();
538
539 for entry in walker {
540 let entry = entry.with_context(|| format!("failed to walk {}", root.display()))?;
541 if entry.file_type().is_some_and(|t| t.is_file()) {
542 files.push(entry.into_path());
543 }
544 }
545
546 if files.is_empty()
547 && let Some(output) = list_output
548 && !output.trim().is_empty()
549 {
550 return Err(anyhow!(
551 "workspace appears empty after File.list: {}",
552 output.trim()
553 ));
554 }
555
556 files.sort();
557
558 Ok(WorkspaceSummary {
559 root: root.to_path_buf(),
560 file_count: files.len(),
561 files,
562 })
563 }
564
565 fn validate_outputs(
566 root: &Path,
567 shell_expect_token: &str,
568 search_output: Option<&str>,
569 edit_output: Option<&str>,
570 patch_output: Option<&str>,
571 shell_output: Option<&str>,
572 ) -> bool {
573 let notes_path = root.join("notes.txt");
574 let notes = match fs::read_to_string(&notes_path) {
575 Ok(content) => content,
576 Err(_) => return false,
577 };
578
579 let search_ok = search_output.is_some_and(|s| s.contains("matches="));
580 let edit_ok = edit_output.is_some_and(|s| !s.is_empty()) && notes.contains("edited = true");
581 let patch_ok = patch_output.is_some_and(|s| !s.is_empty())
582 && notes.contains("todo: offline metrics (patched)");
583 let shell_ok = shell_output
584 .map(str::trim)
585 .is_some_and(|s| s.contains(shell_expect_token));
586
587 search_ok && edit_ok && patch_ok && shell_ok
588 }
589
590 fn list_workspace(path: &Path) -> Result<Vec<String>> {
591 let mut entries = Vec::new();
592 let dir = fs::read_dir(path)
593 .with_context(|| format!("failed to read directory: {}", path.display()))?;
594
595 for entry in dir {
596 let entry = entry.with_context(|| format!("failed to list {}", path.display()))?;
597 entries.push(entry.file_name().to_string_lossy().to_string());
598 }
599
600 entries.sort();
601 Ok(entries)
602 }
603
604 fn read_workspace_file(path: &Path) -> Result<String> {
605 fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))
606 }
607
608 #[derive(Debug, Clone, PartialEq, Eq)]
609 struct SearchMatch {
610 path: PathBuf,
611 line: usize,
612 content: String,
613 }
614
615 #[derive(Debug, Clone, PartialEq, Eq)]
616 struct SearchResult {
617 matches: Vec<SearchMatch>,
618 }
619
620 fn search_files(root: &Path, pattern: &str) -> Result<SearchResult> {
621 if !root.exists() {
622 return Err(anyhow!("search root does not exist: {}", root.display()));
623 }
624
625 let regex = Regex::new(pattern).context("failed to compile search regex")?;
626 let mut matches = Vec::new();
627
628 let walker = WalkBuilder::new(root)
629 .hidden(false)
630 .git_ignore(false)
631 .git_global(false)
632 .git_exclude(false)
633 .build();
634
635 for entry in walker {
636 let entry = entry.with_context(|| format!("failed to walk {}", root.display()))?;
637 if !entry.file_type().is_some_and(|t| t.is_file()) {
638 continue;
639 }
640
641 let path = entry.path();
642 let content = match fs::read_to_string(path) {
643 Ok(c) => c,
644 Err(_) => continue,
645 };
646
647 for (idx, line) in content.lines().enumerate() {
648 if regex.is_match(line) {
649 matches.push(SearchMatch {
650 path: path.to_path_buf(),
651 line: idx + 1,
652 content: line.to_string(),
653 });
654 }
655 if matches.len() >= 64 {
656 break;
657 }
658 }
659 if matches.len() >= 64 {
660 break;
661 }
662 }
663
664 Ok(SearchResult { matches })
665 }
666
667 fn append_workspace_file(path: &Path, line: &str) -> Result<()> {
668 let mut content = read_workspace_file(path)?;
669 if !content.ends_with('\n') {
670 content.push('\n');
671 }
672 content.push_str(line);
673 content.push('\n');
674 fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))
675 }
676
677 fn apply_patch(root: &Path, patch: &str) -> Result<()> {
678 let mut lines = patch.lines();
679
680 let begin = lines.next().unwrap_or_default();
681 if begin != "*** Begin Patch" {
682 return Err(anyhow!("patch missing *** Begin Patch header"));
683 }
684
685 let header = lines.next().unwrap_or_default();
686 let file_rel = header
687 .strip_prefix("*** Update File: ")
688 .ok_or_else(|| anyhow!("only *** Update File patches are supported"))?;
689 if file_rel.contains("..") {
690 return Err(anyhow!("patch path must be workspace-relative"));
691 }
692
693 let file_path = root.join(file_rel);
694 let original = read_workspace_file(&file_path)?;
695 let had_trailing_newline = original.ends_with('\n');
696 let mut file_lines: Vec<String> = original.lines().map(|l| l.to_string()).collect();
697
698 let mut cursor = 0usize;
699 for raw_line in lines {
700 if raw_line == "*** End Patch" {
701 break;
702 }
703 if raw_line.starts_with("*** ") {
704 return Err(anyhow!("unexpected patch directive: {raw_line}"));
705 }
706 if raw_line.starts_with("@@") {
707 continue;
708 }
709
710 let (kind, rest) = raw_line.split_at(1);
711 let content = rest.to_string();
712
713 match kind {
714 " " => {
715 let Some(found) = file_lines[cursor..]
716 .iter()
717 .position(|line| line == &content)
718 .map(|offset| cursor + offset)
719 else {
720 return Err(anyhow!(
721 "patch context not found in {}: {}",
722 file_path.display(),
723 content
724 ));
725 };
726 cursor = found + 1;
727 }
728 "-" => {
729 if cursor >= file_lines.len() || file_lines[cursor] != content {
730 return Err(anyhow!(
731 "patch removal mismatch in {}: expected '{}'",
732 file_path.display(),
733 content
734 ));
735 }
736 file_lines.remove(cursor);
737 }
738 "+" => {
739 file_lines.insert(cursor, content);
740 cursor += 1;
741 }
742 _ => return Err(anyhow!("unsupported patch line: {raw_line}")),
743 }
744 }
745
746 let mut updated = file_lines.join("\n");
747 if had_trailing_newline {
748 updated.push('\n');
749 }
750
751 fs::write(&file_path, updated)
752 .with_context(|| format!("failed to write patched file {}", file_path.display()))
753 }
754
755 fn run_bash(root: &Path, command: &str) -> Result<String> {
756 crate::shell_dispatcher::global_dispatcher().run_foreground(command, root)
757 }
758
759 fn truncate_output(value: &str, max_chars: usize) -> String {
760 if value.chars().count() <= max_chars {
761 return value.to_string();
762 }
763
764 let truncated: String = value.chars().take(max_chars).collect();
765 format!("{truncated}...")
766 }
767
768 #[cfg(test)]
769 mod tests {
770 use super::*;
771
772 #[test]
773 fn eval_shell_invocation_preserves_quoted_payload_as_single_arg() {
774 let command = r#"git commit -m "feat: complete sub-pages""#;
775
776 let windows = eval_shell_invocation_for_platform(command, EvalShellPlatform::Windows);
777 assert_eq!(windows.program, "cmd");
778 assert_eq!(windows.args, vec!["/C".to_string(), command.to_string()]);
779 assert!(windows.raw_payload_on_windows);
780
781 let unix = eval_shell_invocation_for_platform(command, EvalShellPlatform::Unix);
782 assert_eq!(unix.program, "sh");
783 assert_eq!(unix.args, vec!["-c".to_string(), command.to_string()]);
784 assert!(!unix.raw_payload_on_windows);
785 }
786 }
787
787 lines RUST