1use std::fmt::Write as FmtWrite;
23use std::path::{Path, PathBuf};
24use std::time::Instant;
25
26use sha2::{Digest, Sha256};
27
28use crate::config::DatalogConfig;
29
30pub struct DatalogWriter {
32 base_dir: PathBuf,
35 configured_dir: Option<String>,
39 enabled: bool,
41 buf: String,
43 active: bool,
45 start: Option<Instant>,
47 llm_turn_start: Option<Instant>,
49 step: usize,
51 file_path: Option<PathBuf>,
53 filename_tag: Option<String>,
55}
56
57impl DatalogWriter {
58 pub fn new(working_dir: &Path, config: &DatalogConfig) -> Self {
59 Self::new_inner(working_dir, config, None)
60 }
61
62 pub fn new_with_filename_tag(
63 working_dir: &Path,
64 config: &DatalogConfig,
65 filename_tag: &str,
66 ) -> Self {
67 Self::new_inner(
68 working_dir,
69 config,
70 Some(sanitize_filename_tag(filename_tag)),
71 )
72 }
73
74 fn new_inner(working_dir: &Path, config: &DatalogConfig, filename_tag: Option<String>) -> Self {
75 Self {
76 base_dir: working_dir.to_path_buf(),
77 configured_dir: config.dir.clone(),
78 enabled: config.enabled,
79 buf: String::new(),
80 active: false,
81 start: None,
82 llm_turn_start: None,
83 step: 0,
84 file_path: None,
85 filename_tag: filename_tag.filter(|s| !s.is_empty()),
86 }
87 }
88
89 pub fn set_working_dir(&mut self, dir: &Path) {
93 self.base_dir = dir.to_path_buf();
94 }
95
96 pub fn resolve_log_dir(base_dir: &Path, configured: Option<&str>) -> PathBuf {
106 let root = match configured {
107 None => Self::default_root(),
108 Some(s) if s.starts_with("~/") || s == "~" => {
109 let rest = s.strip_prefix("~/").unwrap_or("");
110 crate::tool::real_home_dir()
111 .unwrap_or_else(|| PathBuf::from("."))
112 .join(rest)
113 }
114 Some(s) => {
115 let p = PathBuf::from(s);
116 if p.is_absolute() {
117 p
118 } else {
119 base_dir.join(p)
120 }
121 }
122 };
123 root.join(project_slug(base_dir))
124 }
125
126 fn default_root() -> PathBuf {
131 crate::config::Config::config_dir().join("datalog")
132 }
133
134 pub fn clear(&mut self) {
137 if let Some(ref path) = self.file_path {
139 let _ = std::fs::remove_file(path);
140 }
141 self.buf.clear();
142 self.active = false;
143 self.start = None;
144 self.llm_turn_start = None;
145 self.step = 0;
146 self.file_path = None;
147 }
148
149 fn flush(&self) {
151 if let Some(ref path) = self.file_path {
152 let _ = std::fs::write(path, &self.buf);
153 }
154 }
155
156 pub fn begin_turn(&mut self, user_message: &str, model_name: &str, context_window: usize) {
158 if !self.enabled {
159 return;
160 }
161 self.buf.clear();
162 self.step = 0;
163 self.active = true;
164 self.start = Some(Instant::now());
165
166 let timestamp = format_timestamp();
167 let filename_stem = timestamp.replace(' ', "_").replace(':', "-");
168 let filename = match self.filename_tag.as_deref() {
169 Some(tag) => format!("{filename_stem}_{tag}.md"),
170 None => format!("{filename_stem}.md"),
171 };
172 let log_dir = Self::resolve_log_dir(&self.base_dir, self.configured_dir.as_deref());
173 let _ = std::fs::create_dir_all(&log_dir);
174 self.file_path = Some(log_dir.join(filename));
175
176 let build_id = option_env!("ATOMCODE_BUILD_ID").unwrap_or("dev");
177 let _ = writeln!(&mut self.buf, "# Turn {} [build:{}]", timestamp, build_id);
178 let _ = writeln!(
179 &mut self.buf,
180 "**env:** model={}, ctx_window={}, cwd={}",
181 model_name,
182 context_window,
183 self.base_dir.display()
184 );
185 let _ = writeln!(&mut self.buf);
186 let _ = writeln!(&mut self.buf, "## User");
187 let _ = writeln!(&mut self.buf, "```");
188 let _ = writeln!(&mut self.buf, "{}", user_message);
189 let _ = writeln!(&mut self.buf, "```");
190 let _ = writeln!(&mut self.buf);
191 let _ = writeln!(&mut self.buf, "## Agent");
192 let _ = writeln!(&mut self.buf);
193 self.flush();
194 }
195
196 pub fn log_llm_call(&mut self) {
199 if !self.active {
200 return;
201 }
202 if let Some(prev_start) = self.llm_turn_start {
204 let dur = prev_start.elapsed();
205 if dur.as_millis() >= 1000 {
206 let _ = writeln!(&mut self.buf, " _({:.1}s)_\n", dur.as_secs_f64());
207 }
208 }
209 self.step += 1;
210 let _ = writeln!(&mut self.buf, "### Turn {}", self.step);
211 self.llm_turn_start = Some(Instant::now());
212 self.flush();
213 }
214
215 pub fn log_context_stats(
217 &mut self,
218 system_tokens: usize,
219 sent_tokens: usize,
220 dropped_tokens: usize,
221 _working_set_tokens: usize,
222 total_messages: usize,
223 ) {
224 if !self.active {
225 return;
226 }
227 let total = system_tokens + sent_tokens;
228 let _ = writeln!(
229 &mut self.buf,
230 " _[ctx: {}tok = sys:{}+sent:{}+dropped:{}, msgs:{}]_",
231 total, system_tokens, sent_tokens, dropped_tokens, total_messages
232 );
233 self.flush();
234 }
235
236 pub fn log_cache_hit(&mut self, prompt_tokens: usize, cached_tokens: usize) {
238 if !self.active {
239 return;
240 }
241 let pct = if prompt_tokens > 0 {
242 cached_tokens * 100 / prompt_tokens
243 } else {
244 0
245 };
246 let _ = writeln!(
247 &mut self.buf,
248 " _[cache: {}/{}tok = {}% hit]_",
249 cached_tokens, prompt_tokens, pct
250 );
251 self.flush();
252 }
253
254 pub fn log_token_usage(
256 &mut self,
257 prompt_tokens: usize,
258 completion_tokens: usize,
259 cached_tokens: usize,
260 ) {
261 if !self.active {
262 return;
263 }
264 let cache_str = if cached_tokens > 0 {
265 format!(", cache={}tok", cached_tokens)
266 } else {
267 String::new()
268 };
269 let _ = writeln!(
270 &mut self.buf,
271 " _[tokens: prompt={}+completion={}{}]_",
272 prompt_tokens, completion_tokens, cache_str
273 );
274 self.flush();
275 }
276
277 pub fn log_llm_dump(
282 &mut self,
283 messages: &[crate::conversation::message::Message],
284 tool_count: usize,
285 model: &str,
286 context_window: usize,
287 ) {
288 if !self.active {
289 return;
290 }
291
292 let jsonl_path = self.file_path.as_ref().map(|p| p.with_extension("jsonl"));
294
295 if let Some(ref path) = jsonl_path {
296 let msgs_json = serde_json::to_value(messages).unwrap_or(serde_json::json!([]));
297 let total_tokens: usize = messages.iter().map(|m| m.estimate_tokens()).sum();
298 let dump = serde_json::json!({
299 "step": self.step,
300 "model": model,
301 "context_window": context_window,
302 "message_count": messages.len(),
303 "estimated_tokens": total_tokens,
304 "tool_count": tool_count,
305 "messages": msgs_json,
306 });
307 if let Ok(json_line) = serde_json::to_string(&dump) {
309 use std::io::Write;
310 if let Ok(mut f) = std::fs::OpenOptions::new()
311 .create(true)
312 .append(true)
313 .open(path)
314 {
315 let _ = writeln!(f, "{}", json_line);
316 }
317 }
318 let _ = writeln!(
319 &mut self.buf,
320 " _[request: {}msgs · {}tok · {}tools]_",
321 messages.len(),
322 total_tokens,
323 tool_count
324 );
325 self.flush();
326 }
327 }
328
329 pub fn log_tool_call(&mut self, name: &str, args: &str) {
331 if !self.active {
332 return;
333 }
334
335 let detail = format_tool_args(name, args);
336 let _ = writeln!(&mut self.buf, "- {} {}", capitalize(name), detail);
337 if serde_json::from_str::<serde_json::Value>(args).is_err() {
339 let _ = writeln!(
340 &mut self.buf,
341 " [RAW ARGS: {}]",
342 args.chars().take(200).collect::<String>()
343 );
344 }
345 self.flush();
346 }
347
348 pub fn log_tool_result(&mut self, output: &str, success: bool) {
350 if !self.active {
351 return;
352 }
353 let icon = if success { "+" } else { "x" };
354 let first_line = output.lines().next().unwrap_or("");
355 let summary = if first_line.len() > 100 {
356 format!("{}...", first_line.chars().take(97).collect::<String>())
357 } else {
358 first_line.to_string()
359 };
360 let total_lines = output.lines().count();
361 if total_lines > 1 {
362 let _ = writeln!(
363 &mut self.buf,
364 " {} {} ({} lines)",
365 icon, summary, total_lines
366 );
367 } else {
368 let _ = writeln!(&mut self.buf, " {} {}", icon, summary);
369 }
370 let _ = writeln!(&mut self.buf);
371 self.flush();
372 }
373
374 pub fn log_model_text(&mut self, text: &str) {
376 if !self.active {
377 return;
378 }
379 let trimmed = text.trim();
380 if trimmed.is_empty() {
381 return;
382 }
383 let display = if trimmed.chars().count() > 500 {
385 format!("{}...", trimmed.chars().take(497).collect::<String>())
386 } else {
387 trimmed.to_string()
388 };
389 let _ = writeln!(&mut self.buf, " > {}", display.replace('\n', "\n > "));
390 let _ = writeln!(&mut self.buf);
391 self.flush();
392 }
393
394 pub fn log_text(&mut self, text: &str) {
396 if !self.active {
397 return;
398 }
399 if text.trim().is_empty() {
400 return;
401 }
402 let _ = writeln!(&mut self.buf, "**Response:**");
403 let _ = writeln!(&mut self.buf, "{}", text.trim());
404 let _ = writeln!(&mut self.buf);
405 self.flush();
406 }
407
408 pub fn log_error(&mut self, error: &str) {
410 if !self.active {
411 return;
412 }
413 let _ = writeln!(&mut self.buf, "**Error:** {}", error);
414 let _ = writeln!(&mut self.buf);
415 self.flush();
416 }
417
418 pub fn log_warning(&mut self, warning: &str) {
423 if !self.active {
424 return;
425 }
426 let _ = writeln!(&mut self.buf, "**Warning:** {}", warning);
427 let _ = writeln!(&mut self.buf);
428 self.flush();
429 }
430
431 pub fn end_turn(&mut self, total_tokens: usize, tool_call_count: usize) {
433 if !self.active {
434 return;
435 }
436 self.active = false;
437
438 if let Some(prev_start) = self.llm_turn_start.take() {
440 let dur = prev_start.elapsed();
441 if dur.as_millis() >= 1000 {
442 let _ = writeln!(&mut self.buf, " _({:.1}s)_", dur.as_secs_f64());
443 }
444 }
445
446 let duration = self.start.map(|s| s.elapsed()).unwrap_or_default();
447 let _ = writeln!(&mut self.buf);
448 let _ = writeln!(&mut self.buf, "---");
449 let _ = writeln!(
450 &mut self.buf,
451 "**Stats:** {} turns, {} tool calls, {:.1}s, {} tokens",
452 self.step,
453 tool_call_count,
454 duration.as_secs_f64(),
455 total_tokens,
456 );
457 self.flush();
458 }
459}
460
461fn capitalize(name: &str) -> String {
462 name.split('_')
463 .map(|w| {
464 let mut c = w.chars();
465 match c.next() {
466 None => String::new(),
467 Some(ch) => ch.to_uppercase().to_string() + c.as_str(),
468 }
469 })
470 .collect::<Vec<_>>()
471 .join(" ")
472}
473
474fn format_tool_args(tool_name: &str, args_json: &str) -> String {
475 let args: serde_json::Value = match serde_json::from_str(args_json) {
476 Ok(v) => v,
477 Err(_) => return String::new(),
478 };
479
480 match tool_name {
481 "read_file" => {
482 let path = args.get("file_path").and_then(|v| v.as_str()).unwrap_or("");
483 let short = short_path(path);
484 let mut s = short;
485 if let Some(offset) = args.get("offset").and_then(|v| v.as_u64()) {
486 if let Some(limit) = args.get("limit").and_then(|v| v.as_u64()) {
487 s.push_str(&format!(" L{}-{}", offset, offset + limit));
488 }
489 }
490 s
491 }
492 "create_file" => {
493 let path = args.get("file_path").and_then(|v| v.as_str()).unwrap_or("");
494 let size = args
495 .get("content")
496 .and_then(|v| v.as_str())
497 .map(|s| s.len())
498 .unwrap_or(0);
499 format!("{} ({} bytes)", short_path(path), size)
500 }
501 "edit_file" => {
502 let path = args.get("file_path").and_then(|v| v.as_str()).unwrap_or("");
503 short_path(path)
504 }
505 "bash" => {
506 let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
507 if cmd.chars().count() > 80 {
508 format!("`{}...`", cmd.chars().take(77).collect::<String>())
509 } else {
510 format!("`{}`", cmd)
511 }
512 }
513 "list_directory" => {
514 let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
515 short_path(path)
516 }
517 "grep" => {
518 let pattern = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
519 let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
520 format!("\"{}\" in {}", pattern, short_path(path))
521 }
522 "glob" => {
523 let pattern = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
524 format!("\"{}\"", pattern)
525 }
526 _ => {
527 if let Some(obj) = args.as_object() {
528 obj.iter()
529 .map(|(k, v)| {
530 let val = match v {
531 serde_json::Value::String(s) if s.chars().count() > 30 => {
532 format!("{}...", s.chars().take(27).collect::<String>())
533 }
534 serde_json::Value::String(s) => s.clone(),
535 other => other.to_string(),
536 };
537 format!("{}={}", k, val)
538 })
539 .collect::<Vec<_>>()
540 .join(" ")
541 } else {
542 String::new()
543 }
544 }
545 }
546}
547
548fn short_path(path: &str) -> String {
549 let parts: Vec<&str> = path.rsplitn(3, '/').collect();
550 match parts.len() {
551 0 | 1 => path.to_string(),
552 2 => format!("{}/{}", parts[1], parts[0]),
553 _ => format!(".../{}/{}", parts[1], parts[0]),
554 }
555}
556
557fn format_timestamp() -> String {
559 chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string()
560}
561
562fn sanitize_filename_tag(tag: &str) -> String {
563 tag.chars()
564 .filter_map(|c| {
565 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
566 Some(c)
567 } else if c.is_whitespace() {
568 Some('-')
569 } else {
570 None
571 }
572 })
573 .collect()
574}
575
576fn project_slug(working_dir: &Path) -> String {
589 let canonical = working_dir
590 .canonicalize()
591 .unwrap_or_else(|_| working_dir.to_path_buf());
592 let basename = canonical
593 .file_name()
594 .map(|s| s.to_string_lossy().to_string())
595 .filter(|s| !s.is_empty())
596 .unwrap_or_else(|| "root".to_string());
597 let safe: String = basename
598 .chars()
599 .map(|c| {
600 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
601 c
602 } else {
603 '_'
604 }
605 })
606 .collect();
607 let mut hasher = Sha256::new();
608 hasher.update(canonical.to_string_lossy().as_bytes());
609 let digest = hasher.finalize();
610 format!(
611 "{}-{:02x}{:02x}{:02x}{:02x}",
612 safe, digest[0], digest[1], digest[2], digest[3]
613 )
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 fn make_log(dir: &Path) -> DatalogWriter {
621 let cfg = DatalogConfig {
626 enabled: true,
627 dir: Some(dir.to_string_lossy().to_string()),
628 };
629 let mut log = DatalogWriter::new(dir, &cfg);
630 log.begin_turn("test", "test-model", 16000);
631 log.log_llm_call();
632 log
633 }
634
635 #[test]
636 fn resolve_log_dir_default_lands_under_home() {
637 let base = PathBuf::from("/tmp/work");
638 let p = DatalogWriter::resolve_log_dir(&base, None);
639 let expected_root = crate::config::Config::config_dir().join("datalog");
642 assert!(
643 p.starts_with(&expected_root),
644 "{:?} should start with {:?}",
645 p,
646 expected_root
647 );
648 let slug = p.file_name().unwrap().to_string_lossy().to_string();
652 assert!(slug.starts_with("work-"), "slug {:?}", slug);
653 let hex_tail = slug.rsplit('-').next().unwrap();
654 assert_eq!(hex_tail.len(), 8, "hash tail should be 8 hex chars");
655 assert!(hex_tail.chars().all(|c| c.is_ascii_hexdigit()));
656 }
657
658 #[test]
659 fn resolve_log_dir_absolute_uses_configured_root_with_slug() {
660 let base = PathBuf::from("/tmp/work");
661 let p = DatalogWriter::resolve_log_dir(&base, Some("/var/logs/atomcode"));
662 assert!(p.starts_with("/var/logs/atomcode"));
663 assert!(p
664 .file_name()
665 .unwrap()
666 .to_string_lossy()
667 .starts_with("work-"));
668 }
669
670 #[test]
671 fn resolve_log_dir_relative_joins_working_dir_then_slug() {
672 let base = PathBuf::from("/tmp/work");
673 let p = DatalogWriter::resolve_log_dir(&base, Some("logs/ac"));
674 assert!(p.starts_with("/tmp/work/logs/ac"));
675 assert!(p
676 .file_name()
677 .unwrap()
678 .to_string_lossy()
679 .starts_with("work-"));
680 }
681
682 #[test]
683 fn resolve_log_dir_tilde_expands_home() {
684 let base = PathBuf::from("/tmp/work");
685 let p = DatalogWriter::resolve_log_dir(&base, Some("~/.atomcode/logs"));
686 let expected_root = crate::tool::real_home_dir().unwrap().join(".atomcode/logs");
690 assert!(p.starts_with(&expected_root));
691 assert!(p
692 .file_name()
693 .unwrap()
694 .to_string_lossy()
695 .starts_with("work-"));
696 }
697
698 #[test]
699 fn project_slug_is_stable_for_same_path() {
700 let p = PathBuf::from("/tmp/repeatable-path");
701 let s1 = project_slug(&p);
702 let s2 = project_slug(&p);
703 assert_eq!(s1, s2, "slug must be deterministic");
704 }
705
706 #[test]
707 fn project_slug_disambiguates_same_basename() {
708 let a = PathBuf::from("/tmp/dup-test-a/foo");
709 let b = PathBuf::from("/tmp/dup-test-b/foo");
710 let sa = project_slug(&a);
711 let sb = project_slug(&b);
712 assert!(sa.starts_with("foo-"));
713 assert!(sb.starts_with("foo-"));
714 assert_ne!(sa, sb, "different parents must yield different slugs");
715 }
716
717 #[test]
718 fn format_timestamp_produces_correct_format() {
719 let ts = format_timestamp();
720 let parts: Vec<&str> = ts.split(' ').collect();
722 assert_eq!(parts.len(), 2, "Timestamp should have date and time parts");
723
724 let date_parts: Vec<&str> = parts[0].split('-').collect();
726 assert_eq!(date_parts.len(), 3, "Date should have 3 parts");
727 assert_eq!(date_parts[0].len(), 4, "Year should be 4 digits");
728 assert_eq!(date_parts[1].len(), 2, "Month should be 2 digits");
729 assert_eq!(date_parts[2].len(), 2, "Day should be 2 digits");
730
731 let time_parts: Vec<&str> = parts[1].split(':').collect();
733 assert_eq!(time_parts.len(), 3, "Time should have 3 parts");
734 assert_eq!(time_parts[0].len(), 2, "Hour should be 2 digits");
735 assert_eq!(time_parts[1].len(), 2, "Minute should be 2 digits");
736 assert_eq!(time_parts[2].len(), 2, "Second should be 2 digits");
737
738 let hour: u32 = time_parts[0].parse().expect("Hour should be numeric");
740 let minute: u32 = time_parts[1].parse().expect("Minute should be numeric");
741 let second: u32 = time_parts[2].parse().expect("Second should be numeric");
742 assert!(hour < 24, "Hour should be 0-23");
743 assert!(minute < 60, "Minute should be 0-59");
744 assert!(second < 60, "Second should be 0-59");
745 }
746
747 #[test]
748 fn disabled_writer_never_creates_files() {
749 let dir = std::env::temp_dir().join("atomcode_test_datalog_disabled");
753 let _ = std::fs::remove_dir_all(&dir);
754 let cfg = DatalogConfig {
755 enabled: false,
756 dir: Some(dir.to_string_lossy().to_string()),
757 };
758 let mut log = DatalogWriter::new(&dir, &cfg);
759 log.begin_turn("hello", "m", 1000);
760 log.log_llm_call();
761 log.log_text("response");
762 log.end_turn(0, 0);
763 assert!(log.file_path.is_none());
764 assert!(
765 !dir.exists(),
766 "disabled writer must not create the root dir"
767 );
768 }
769
770 #[test]
771 fn filename_tag_is_added_only_for_tagged_writer() {
772 let dir = std::env::temp_dir().join("atomcode_test_datalog_filename_tag");
773 let _ = std::fs::remove_dir_all(&dir);
774 let cfg = DatalogConfig {
775 enabled: true,
776 dir: Some(dir.to_string_lossy().to_string()),
777 };
778
779 let mut default_log = DatalogWriter::new(&dir, &cfg);
780 default_log.begin_turn("hello", "m", 1000);
781 let default_name = default_log
782 .file_path
783 .as_ref()
784 .unwrap()
785 .file_name()
786 .unwrap()
787 .to_string_lossy()
788 .to_string();
789 assert!(!default_name.contains("runtime-2"));
790
791 let mut tagged_log = DatalogWriter::new_with_filename_tag(&dir, &cfg, "runtime-2");
792 tagged_log.begin_turn("hello", "m", 1000);
793 let tagged_name = tagged_log
794 .file_path
795 .as_ref()
796 .unwrap()
797 .file_name()
798 .unwrap()
799 .to_string_lossy()
800 .to_string();
801 assert!(tagged_name.ends_with("_runtime-2.md"));
802
803 let _ = std::fs::remove_dir_all(&dir);
804 }
805
806 #[test]
807 fn test_log_model_text_chinese_truncation() {
808 let dir = std::env::temp_dir().join("atomcode_test_datalog_cn");
809 let _ = std::fs::create_dir_all(&dir);
810 let mut log = make_log(&dir);
811
812 let long_chinese = "这是一段很长的中文文本用于测试截断逻辑".repeat(30);
813 assert!(long_chinese.chars().count() > 500);
814
815 log.log_model_text(&long_chinese);
816
817 let content = std::fs::read_to_string(log.file_path.as_ref().unwrap()).unwrap();
818 assert!(content.contains("..."));
819
820 let _ = std::fs::remove_dir_all(&dir);
821 }
822
823 #[test]
824 fn test_log_model_text_short_no_truncation() {
825 let dir = std::env::temp_dir().join("atomcode_test_datalog_short");
826 let _ = std::fs::create_dir_all(&dir);
827 let mut log = make_log(&dir);
828
829 log.log_model_text("短文本");
830
831 let content = std::fs::read_to_string(log.file_path.as_ref().unwrap()).unwrap();
832 assert!(content.contains("短文本"));
833 assert!(!content.contains("..."));
834
835 let _ = std::fs::remove_dir_all(&dir);
836 }
837
838 #[test]
839 fn test_log_model_text_mixed_unicode() {
840 let dir = std::env::temp_dir().join("atomcode_test_datalog_mixed");
841 let _ = std::fs::create_dir_all(&dir);
842 let mut log = make_log(&dir);
843
844 let mixed = format!("Hello 你好 {} end", "🎉测试".repeat(200));
845 assert!(mixed.chars().count() > 500);
846
847 log.log_model_text(&mixed);
848
849 let _ = std::fs::remove_dir_all(&dir);
850 }
851
852 #[test]
853 fn test_end_turn_stats_format() {
854 let dir = std::env::temp_dir().join("atomcode_test_datalog_stats");
855 let _ = std::fs::create_dir_all(&dir);
856 let mut log = make_log(&dir);
857
858 log.log_tool_call("bash", r#"{"command":"ls"}"#);
859 log.log_tool_result("file.txt", true);
860 log.end_turn(1000, 3);
861
862 let content = std::fs::read_to_string(log.file_path.as_ref().unwrap()).unwrap();
863 assert!(content.contains("1 turns"));
864 assert!(content.contains("3 tool calls"));
865 assert!(content.contains("1000 tokens"));
866
867 let _ = std::fs::remove_dir_all(&dir);
868 }
869}