Skip to main content

atomcode_core/turn/
datalog.rs

1//! Per-turn logging: writes each user request and agent response to a markdown
2//! file under a configurable root, with one subdirectory per project so multiple
3//! projects don't pile their logs into a single bucket.
4//!
5//! Default layout:
6//!   $ATOMCODE_HOME/datalog/<project-slug>/YYYY-MM-DD_HH-MM-SS.md
7//!   $ATOMCODE_HOME/datalog/<project-slug>/llm/YYYY-MM-DD_HH-MM-SS_sss.json
8//!
9//! Project slug = sanitized cwd basename + 8-char sha256 prefix of the canonical
10//! cwd path. The hash suffix prevents collisions when two unrelated projects
11//! share a basename (e.g. `~/work/foo` vs `~/personal/foo`).
12//!
13//! Content mirrors what the user sees on screen.
14//! Every write operation flushes immediately so logs survive crashes.
15//!
16//! Configured via `[datalog]` in `$ATOMCODE_HOME/config.toml`:
17//! - `enabled = false`     — disables logging entirely (writer becomes a no-op)
18//! - `dir = "<path>"`      — root directory; project slug is always appended
19//!   underneath. Accepts absolute, `~/…`, or relative paths. Default
20//!   `"$ATOMCODE_HOME/datalog"`.
21
22use 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
30/// Accumulates log entries for a single turn, flushing to disk after each operation.
31pub struct DatalogWriter {
32    /// Current working directory — used to derive the per-project slug, and to
33    /// resolve `configured_dir` when it's relative.
34    base_dir: PathBuf,
35    /// Raw user-configured root (pre-resolution). `None` → default to
36    /// `$ATOMCODE_HOME/datalog`. Otherwise absolute, `~/…`, or relative. The
37    /// project slug is always appended underneath whichever value resolves.
38    configured_dir: Option<String>,
39    /// When false, all methods are no-ops and no files are created.
40    enabled: bool,
41    /// Content buffer
42    buf: String,
43    /// Whether we have an active turn
44    active: bool,
45    /// Turn start time (for total duration)
46    start: Option<Instant>,
47    /// Per-LLM-turn timer (reset on each log_llm_call)
48    llm_turn_start: Option<Instant>,
49    /// Step counter (LLM turn count)
50    step: usize,
51    /// File path for this turn
52    file_path: Option<PathBuf>,
53    /// Optional suffix inserted into the markdown filename before `.md`.
54    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    /// Update the working directory (e.g. after `/cd`). Absolute / `~/`
90    /// configured paths are unaffected — only the default and relative cases
91    /// follow cwd changes.
92    pub fn set_working_dir(&mut self, dir: &Path) {
93        self.base_dir = dir.to_path_buf();
94    }
95
96    /// Resolve the actual directory to write datalog files into, given the
97    /// current `base_dir` and `configured_dir`. Pure function — used by
98    /// `begin_turn`, by `runner.rs` to decide where `log_llm_request` writes
99    /// the JSONL request dump (so both writers stay in sync), and covered by
100    /// unit tests.
101    ///
102    /// The result is always `<root>/<project-slug>` so multiple projects don't
103    /// collide. `<root>` is `$ATOMCODE_HOME/datalog` by default, or whatever the
104    /// user configured (absolute / `~/…` / relative-to-cwd).
105    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    /// `$ATOMCODE_HOME/datalog` — the built-in root used when `[datalog].dir`
127    /// is unset. Respects `ATOMCODE_HOME` environment variable if set.
128    /// Falls back to a CWD-relative path on the (vanishingly rare)
129    /// platforms where `$HOME` can't be resolved.
130    fn default_root() -> PathBuf {
131        crate::config::Config::config_dir().join("datalog")
132    }
133
134    /// Clear the current turn log state and delete the log file if it exists.
135    /// Called when user runs /clear command.
136    pub fn clear(&mut self) {
137        // Delete the current log file if it exists
138        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    /// Flush current buffer to disk immediately.
150    fn flush(&self) {
151        if let Some(ref path) = self.file_path {
152            let _ = std::fs::write(path, &self.buf);
153        }
154    }
155
156    /// Start a new turn: create log file, write env info + user message.
157    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    /// Log start of a new LLM round-trip (increments the turn counter).
197    /// Records the duration of the previous LLM turn.
198    pub fn log_llm_call(&mut self) {
199        if !self.active {
200            return;
201        }
202        // Log previous turn's duration
203        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    /// Log context statistics for debugging.
216    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    /// Log prompt cache hit info (only when provider reports cached_tokens > 0).
237    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    /// Log token usage for the current LLM round-trip.
255    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    /// Dump full LLM request as JSON into the datalog for debugging.
278    /// Appends to a single JSONL file (one JSON object per line) colocated
279    /// with the turn .md file: `<turn_timestamp>_requests.jsonl`.
280    /// Each line has the step number so you can correlate with the md.
281    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        // Derive JSONL path from the md file path: same name but .jsonl extension
293        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            // Append as single line (compact JSON) to JSONL
308            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    /// Log a tool call start (within the current LLM turn).
330    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        // Log raw args when JSON is invalid (for debugging model output)
338        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    /// Log a tool call result.
349    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    /// Log model text output between tool calls (plan, thinking, explanation).
375    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        // Cap at 500 chars to avoid bloating datalog
384        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    /// Log final assistant text output (response/summary).
395    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    /// Log an error.
409    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    /// Log a non-fatal advisory (e.g. provider truncation detector).
419    /// Persisting it here makes post-hoc datalog inspection useful even
420    /// when the live TUI line scrolls past — the user can grep the
421    /// markdown for `**Warning:**` after the run.
422    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    /// End the turn: write duration and final flush.
432    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        // Log last LLM turn duration
439        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
557/// Format current local time as "YYYY-MM-DD HH:MM:SS".
558fn 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
576/// Per-project slug derived from `working_dir`. Shape: `<basename>-<hash8>`.
577///
578/// - `<basename>`: the last path component, with anything outside
579///   `[A-Za-z0-9_-]` collapsed to `_` (filesystem-safe on every OS).
580/// - `<hash8>`: first 8 hex chars of sha256 over the canonical absolute path.
581///   Disambiguates same-name projects (`~/work/foo` vs `~/personal/foo`) and
582///   keeps the slug stable across atomcode releases (sha2 is a fixed algorithm,
583///   unlike `DefaultHasher` whose output the std reserves the right to change).
584///
585/// `canonicalize` may fail on freshly-deleted dirs / weird permissions; in that
586/// case we hash the as-given path so the slug is still deterministic and
587/// non-canonical paths just get their own bucket (acceptable — beats panicking).
588fn 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        // Pin `dir` explicitly so tests write under the temp root, not the
622        // real `$ATOMCODE_HOME/datalog/` (the new default). Slug subdir still
623        // gets appended underneath — file_path lookups in the test go via
624        // `log.file_path`, so the exact slugged path is opaque to callers.
625        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        // default_root() uses Config::config_dir().join("datalog"),
640        // which resolves ATOMCODE_HOME when set, else $HOME/.atomcode.
641        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        // Slug = basename + 8-hex hash. `/tmp/work` may not exist on the test
649        // host (canonicalize fails → we hash the as-given path), so just
650        // assert the shape: starts with "work-" and ends with 8 hex chars.
651        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        // `~/.atomcode/logs` expands via real_home_dir, which always resolves
687        // to the system home (not ATOMCODE_HOME) — this is the same as the
688        // `~/` expansion in the datalog dir config.
689        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        // Should match format: YYYY-MM-DD HH:MM:SS
721        let parts: Vec<&str> = ts.split(' ').collect();
722        assert_eq!(parts.len(), 2, "Timestamp should have date and time parts");
723
724        // Check date format: YYYY-MM-DD
725        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        // Check time format: HH:MM:SS
732        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        // Verify numeric values are in valid ranges
739        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        // Point `dir` at a temp subdir so this test doesn't depend on (or
750        // pollute) the real `$ATOMCODE_HOME/datalog/`. With enabled=false the
751        // writer should still create nothing under that root.
752        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}