atomcode_core/tool/
result_store.rs1use std::collections::hash_map::DefaultHasher;
2use std::hash::{Hash, Hasher};
3use std::path::PathBuf;
4
5use crate::tool::ToolResult;
6
7#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10pub struct ToolResultRef {
11 pub call_id: String,
12 pub hash: String,
14 pub summary: String,
16 pub byte_size: usize,
18 pub success: bool,
19}
20
21pub struct ToolResultStore {
26 cache_dir: PathBuf,
27}
28
29impl ToolResultStore {
30 pub fn new(cache_dir: PathBuf) -> Self {
31 let _ = std::fs::create_dir_all(&cache_dir);
32 Self { cache_dir }
33 }
34
35 pub fn default_dir() -> PathBuf {
37 crate::config::Config::config_dir().join("tool_cache")
38 }
39
40 pub fn store(&self, result: &ToolResult) -> ToolResultRef {
42 let hash = content_hash(&result.output);
43 let path = self.cache_dir.join(format!("{}.txt", hash));
44
45 if !path.exists() {
47 let _ = std::fs::write(&path, &result.output);
48 }
49
50 ToolResultRef {
51 call_id: result.call_id.clone(),
52 hash,
53 summary: make_summary(&result.output, result.success),
54 byte_size: result.output.len(),
55 success: result.success,
56 }
57 }
58
59 pub fn load(&self, ref_: &ToolResultRef) -> Option<String> {
61 let path = self.cache_dir.join(format!("{}.txt", ref_.hash));
62 std::fs::read_to_string(&path).ok()
63 }
64
65 pub fn inflate(&self, ref_: &ToolResultRef) -> ToolResult {
68 let output = self.load(ref_).unwrap_or_else(|| ref_.summary.clone());
69 ToolResult {
70 call_id: ref_.call_id.clone(),
71 output,
72 success: ref_.success,
73 }
74 }
75
76 pub fn clear(&self) {
78 if let Ok(entries) = std::fs::read_dir(&self.cache_dir) {
79 for entry in entries.flatten() {
80 let _ = std::fs::remove_file(entry.path());
81 }
82 }
83 }
84}
85
86fn content_hash(content: &str) -> String {
89 let mut hasher = DefaultHasher::new();
90 content.hash(&mut hasher);
91 format!("{:016x}", hasher.finish())
92}
93
94fn make_summary(output: &str, success: bool) -> String {
96 let first_line = output
97 .lines()
98 .next()
99 .unwrap_or(if success { "OK" } else { "Error" });
100 if success {
101 if first_line.chars().count() > 100 {
102 format!("{}...", first_line.chars().take(97).collect::<String>())
103 } else {
104 first_line.to_string()
105 }
106 } else {
107 let trimmed = if first_line.chars().count() > 80 {
108 format!("{}...", first_line.chars().take(77).collect::<String>())
109 } else {
110 first_line.to_string()
111 };
112 format!("FAILED: {}", trimmed)
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 use crate::tool::ToolResult;
120
121 fn temp_store() -> (ToolResultStore, tempfile::TempDir) {
122 let dir = tempfile::tempdir().unwrap();
123 let store = ToolResultStore::new(dir.path().to_path_buf());
124 (store, dir)
125 }
126
127 #[test]
128 fn test_store_and_load() {
129 let (store, _dir) = temp_store();
130 let result = ToolResult {
131 call_id: "c1".into(),
132 output: "hello world\nsecond line".into(),
133 success: true,
134 };
135 let ref_ = store.store(&result);
136 assert_eq!(ref_.call_id, "c1");
137 assert!(ref_.success);
138 assert_eq!(ref_.byte_size, result.output.len());
139 assert_eq!(ref_.summary, "hello world");
140
141 let loaded = store.load(&ref_).unwrap();
142 assert_eq!(loaded, "hello world\nsecond line");
143 }
144
145 #[test]
146 fn test_store_idempotent() {
147 let (store, _dir) = temp_store();
148 let result = ToolResult {
149 call_id: "c1".into(),
150 output: "same content".into(),
151 success: true,
152 };
153 let ref1 = store.store(&result);
154 let ref2 = store.store(&result);
155 assert_eq!(ref1.hash, ref2.hash);
156 }
157
158 #[test]
159 fn test_inflate_fallback_to_summary() {
160 let (store, _dir) = temp_store();
161 let ref_ = ToolResultRef {
162 call_id: "c1".into(),
163 hash: "nonexistent_hash".into(),
164 summary: "fallback summary".into(),
165 byte_size: 100,
166 success: true,
167 };
168 let inflated = store.inflate(&ref_);
169 assert_eq!(inflated.output, "fallback summary");
170 }
171
172 #[test]
173 fn test_failure_summary() {
174 let (store, _dir) = temp_store();
175 let result = ToolResult {
176 call_id: "c1".into(),
177 output: "command not found: xyz".into(),
178 success: false,
179 };
180 let ref_ = store.store(&result);
181 assert_eq!(ref_.summary, "FAILED: command not found: xyz");
182 }
183
184 #[test]
185 fn test_long_output_summary_truncation() {
186 let (store, _dir) = temp_store();
187 let long_line = "x".repeat(200);
188 let result = ToolResult {
189 call_id: "c1".into(),
190 output: long_line,
191 success: true,
192 };
193 let ref_ = store.store(&result);
194 assert!(ref_.summary.chars().count() <= 100);
195 assert!(ref_.summary.ends_with("..."));
196 }
197
198 #[test]
199 fn test_clear() {
200 let (store, _dir) = temp_store();
201 let result = ToolResult {
202 call_id: "c1".into(),
203 output: "data".into(),
204 success: true,
205 };
206 let ref_ = store.store(&result);
207 assert!(store.load(&ref_).is_some());
208 store.clear();
209 assert!(store.load(&ref_).is_none());
210 }
211}