first commit

This commit is contained in:
2026-03-08 15:40:34 +07:00
commit 8dc496b626
159 changed files with 27932 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
package logging
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/charmbracelet/log"
)
func NewSessionLogger() (*log.Logger, *os.File, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, nil, fmt.Errorf("home dir: %w", err)
}
logDir := filepath.Join(home, ".config", "ai-agent", "logs")
if err := os.MkdirAll(logDir, 0o755); err != nil {
return nil, nil, fmt.Errorf("create log dir: %w", err)
}
filename := time.Now().Format("2006-01-02_15-04-05") + ".log"
f, err := os.Create(filepath.Join(logDir, filename))
if err != nil {
return nil, nil, fmt.Errorf("create log file: %w", err)
}
logger := log.NewWithOptions(f, log.Options{
ReportTimestamp: true,
TimeFormat: time.RFC3339,
Prefix: "ai-agent",
Level: log.DebugLevel,
})
return logger, f, nil
}
+42
View File
@@ -0,0 +1,42 @@
package logging
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestNewSessionLogger(t *testing.T) {
logger, f, err := NewSessionLogger()
if err != nil {
t.Fatalf("NewSessionLogger() error: %v", err)
}
if f != nil {
defer f.Close()
defer os.Remove(f.Name())
}
if logger == nil {
t.Fatal("logger should not be nil")
}
if f == nil {
t.Fatal("file should not be nil")
}
dir := filepath.Dir(f.Name())
if !strings.Contains(dir, filepath.Join(".config", "ai-agent", "logs")) {
t.Errorf("log file should be in ~/.config/ai-agent/logs/, got %q", dir)
}
logger.Info("test message", "key", "value")
}
func TestNilLoggerNoPanic(t *testing.T) {
var called bool
logger, f, err := NewSessionLogger()
if err == nil && f != nil {
defer f.Close()
defer os.Remove(f.Name())
logger.Info("test")
called = true
}
_ = called
}
+92
View File
@@ -0,0 +1,92 @@
package logging
import (
"bufio"
"fmt"
"os"
"path/filepath"
"sort"
"time"
)
type LogEntry struct {
Path string
ModTime time.Time
Size int64
}
func LogDir() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".config", "ai-agent", "logs")
}
func ListLogs(n int) ([]LogEntry, error) {
return listLogsIn(LogDir(), n)
}
func listLogsIn(dir string, n int) ([]LogEntry, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("read log dir: %w", err)
}
var logs []LogEntry
for _, e := range entries {
if e.IsDir() {
continue
}
info, err := e.Info()
if err != nil {
continue
}
logs = append(logs, LogEntry{
Path: filepath.Join(dir, e.Name()),
ModTime: info.ModTime(),
Size: info.Size(),
})
}
sort.Slice(logs, func(i, j int) bool {
return logs[i].ModTime.After(logs[j].ModTime)
})
if n > 0 && n < len(logs) {
logs = logs[:n]
}
return logs, nil
}
func LatestLogPath() (string, error) {
return latestLogPathIn(LogDir())
}
func latestLogPathIn(dir string) (string, error) {
logs, err := listLogsIn(dir, 1)
if err != nil {
return "", err
}
if len(logs) == 0 {
return "", fmt.Errorf("no log files found in %s", dir)
}
return logs[0].Path, nil
}
func TailLog(path string, n int) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open log: %w", err)
}
defer f.Close()
var lines []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("read log: %w", err)
}
if n > 0 && n < len(lines) {
lines = lines[len(lines)-n:]
}
return lines, nil
}
+157
View File
@@ -0,0 +1,157 @@
package logging
import (
"os"
"path/filepath"
"testing"
"time"
)
// helper creates n temp log files in dir with distinct mod times.
func createFakeLogs(t *testing.T, dir string, n int) []string {
t.Helper()
var paths []string
for i := range n {
name := filepath.Join(dir, "2025-01-01_00-00-0"+string(rune('0'+i))+".log")
if err := os.WriteFile(name, []byte("line "+string(rune('0'+i))+"\n"), 0o644); err != nil {
t.Fatal(err)
}
// Stagger mod times so ordering is deterministic.
ts := time.Now().Add(time.Duration(i) * time.Second)
if err := os.Chtimes(name, ts, ts); err != nil {
t.Fatal(err)
}
paths = append(paths, name)
}
return paths
}
func TestListLogs(t *testing.T) {
dir := t.TempDir()
createFakeLogs(t, dir, 5)
logs, err := listLogsIn(dir, 3)
if err != nil {
t.Fatalf("listLogsIn error: %v", err)
}
if len(logs) != 3 {
t.Fatalf("expected 3 entries, got %d", len(logs))
}
// Verify newest-first ordering.
for i := 1; i < len(logs); i++ {
if logs[i].ModTime.After(logs[i-1].ModTime) {
t.Errorf("entry %d (%v) is newer than entry %d (%v)", i, logs[i].ModTime, i-1, logs[i-1].ModTime)
}
}
}
func TestListLogs_All(t *testing.T) {
dir := t.TempDir()
createFakeLogs(t, dir, 4)
logs, err := listLogsIn(dir, 0)
if err != nil {
t.Fatalf("listLogsIn error: %v", err)
}
if len(logs) != 4 {
t.Fatalf("expected 4 entries, got %d", len(logs))
}
}
func TestListLogs_EmptyDir(t *testing.T) {
dir := t.TempDir()
logs, err := listLogsIn(dir, 5)
if err != nil {
t.Fatalf("listLogsIn error: %v", err)
}
if len(logs) != 0 {
t.Fatalf("expected 0 entries, got %d", len(logs))
}
}
func TestListLogs_MissingDir(t *testing.T) {
_, err := listLogsIn("/tmp/nonexistent-log-dir-test-xyz", 5)
if err == nil {
t.Fatal("expected error for missing dir")
}
}
func TestTailLog(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.log")
content := "line1\nline2\nline3\nline4\nline5\n"
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
lines, err := TailLog(path, 3)
if err != nil {
t.Fatalf("TailLog error: %v", err)
}
if len(lines) != 3 {
t.Fatalf("expected 3 lines, got %d", len(lines))
}
if lines[0] != "line3" {
t.Errorf("expected 'line3', got %q", lines[0])
}
if lines[2] != "line5" {
t.Errorf("expected 'line5', got %q", lines[2])
}
}
func TestTailLog_FewerLines(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "short.log")
if err := os.WriteFile(path, []byte("only\n"), 0o644); err != nil {
t.Fatal(err)
}
lines, err := TailLog(path, 100)
if err != nil {
t.Fatalf("TailLog error: %v", err)
}
if len(lines) != 1 {
t.Fatalf("expected 1 line, got %d", len(lines))
}
}
func TestTailLog_MissingFile(t *testing.T) {
_, err := TailLog("/tmp/nonexistent-file-test-xyz.log", 10)
if err == nil {
t.Fatal("expected error for missing file")
}
}
func TestLatestLogPath(t *testing.T) {
dir := t.TempDir()
paths := createFakeLogs(t, dir, 3)
latest, err := latestLogPathIn(dir)
if err != nil {
t.Fatalf("latestLogPathIn error: %v", err)
}
// The last created file has the newest mod time.
expected := paths[len(paths)-1]
if latest != expected {
t.Errorf("expected %q, got %q", expected, latest)
}
}
func TestLatestLogPath_EmptyDir(t *testing.T) {
dir := t.TempDir()
_, err := latestLogPathIn(dir)
if err == nil {
t.Fatal("expected error for empty dir")
}
}
func TestLogDir(t *testing.T) {
dir := LogDir()
if dir == "" {
t.Fatal("LogDir should not be empty")
}
if filepath.Base(dir) != "logs" {
t.Errorf("expected dir to end in 'logs', got %q", dir)
}
}