Skip to content

Commit a5c6ea5

Browse files
SuperCoolPencilMeet Mehta
andauthored
feat: add new log retention setting (#65)
* feat: Implement configurable log file retention and cleanup, including a verification script. * test: add `TestCleanupLogs` to verify correct log file cleanup and retention. * feat: Add log retention count setting to the TUI. * feat: Introduce Go duration parsing and update download benchmark to use `server start` command with refined time extraction. * feat: call SyncSessionStart when initializing a fresh download state. --------- Co-authored-by: Meet Mehta <supercoolpencil@duck.com>
1 parent ffacf74 commit a5c6ea5

7 files changed

Lines changed: 185 additions & 12 deletions

File tree

‎benchmark.py‎

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import tempfile
1515
import time
1616
import random
17+
import re
1718
from dataclasses import dataclass
1819
from pathlib import Path
1920
from typing import Optional
@@ -93,6 +94,7 @@ def get_file_size(path: Path) -> int:
9394
return 0
9495

9596

97+
9698
def cleanup_file(path: Path):
9799
"""Remove a file if it exists."""
98100
try:
@@ -102,6 +104,29 @@ def cleanup_file(path: Path):
102104
pass
103105

104106

107+
def parse_go_duration(s: str) -> float:
108+
"""Parse Go duration string to seconds."""
109+
total = 0.0
110+
# Match pairs like 1h, 2m, 3.5s, 500ms, 10µs, 100ns
111+
matches = re.findall(r'(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)', s)
112+
for val_str, unit in matches:
113+
val = float(val_str)
114+
if unit == 'h':
115+
total += val * 3600
116+
elif unit == 'm':
117+
total += val * 60
118+
elif unit == 's':
119+
total += val
120+
elif unit == 'ms':
121+
total += val / 1000
122+
elif unit in ('us', 'µs'):
123+
total += val / 1_000_000
124+
elif unit == 'ns':
125+
total += val / 1_000_000_000
126+
127+
return total
128+
129+
105130
# =============================================================================
106131
# SETUP FUNCTIONS
107132
# =============================================================================
@@ -155,25 +180,25 @@ def benchmark_surge(executable: Path, url: str, output_dir: Path, label: str = "
155180
return BenchmarkResult(label, False, 0, 0, f"Binary not found: {executable}")
156181

157182
start = time.perf_counter()
183+
# Use server start mode with exit-when-done
158184
success, output = run_command([
159-
str(executable), "get", url,
160-
"--output", str(output_dir), # Download directory
185+
str(executable), "server", "start", url,
186+
"--output", str(output_dir),
187+
"--exit-when-done"
161188
], timeout=600)
162189
elapsed = time.perf_counter() - start
163190

164-
# Try to parse the actual download time from Surge output (excluding probing)
165-
# Output format: "Complete: 1.0 GB in 5.2s (196.34 MB/s)" OR "... in 500ms ..."
166-
import re
191+
# Try to parse the actual download time from Surge output
192+
# Output format: "Completed: filename [id] (in 5.2s)"
167193
actual_time = elapsed
168-
match = re.search(r"in ([\d\.]+)(m?s)", output)
194+
match = re.search(r"Completed: .*? \[.*?\] \(in (.*?)\)", output)
169195
if match:
170196
try:
171-
val = float(match.group(1))
172-
unit = match.group(2)
173-
if unit == "ms":
174-
val /= 1000.0
175-
actual_time = val
176-
except ValueError:
197+
duration_str = match.group(1)
198+
parsed_time = parse_go_duration(duration_str)
199+
if parsed_time > 0:
200+
actual_time = parsed_time
201+
except Exception:
177202
pass
178203

179204
# Find downloaded file (surge uses original filename)

‎cmd/root.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,16 @@ func initializeGlobalState() {
729729

730730
// Config logging
731731
utils.ConfigureDebug(logsDir)
732+
733+
// Clean up old logs
734+
settings, err := config.LoadSettings()
735+
retention := 5 // default fallback
736+
if err == nil {
737+
retention = settings.General.LogRetentionCount
738+
} else {
739+
retention = config.DefaultSettings().General.LogRetentionCount
740+
}
741+
utils.CleanupLogs(retention)
732742
}
733743

734744
// convertRuntimeConfig converts config.RuntimeConfig to types.RuntimeConfig

‎internal/config/settings.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type GeneralSettings struct {
2525
MaxConcurrentDownloads int `json:"max_concurrent_downloads"`
2626
ClipboardMonitor bool `json:"clipboard_monitor"`
2727
Theme int `json:"theme"`
28+
LogRetentionCount int `json:"log_retention_count"`
2829
}
2930

3031
const (
@@ -77,6 +78,7 @@ func GetSettingsMetadata() map[string][]SettingMeta {
7778
{Key: "max_concurrent_downloads", Label: "Max Concurrent Downloads", Description: "Maximum number of downloads running at once (1-10). Requires restart.", Type: "int"},
7879
{Key: "clipboard_monitor", Label: "Clipboard Monitor", Description: "Watch clipboard for URLs and prompt to download them.", Type: "bool"},
7980
{Key: "theme", Label: "App Theme", Description: "UI Theme (System, Light, Dark).", Type: "int"},
81+
{Key: "log_retention_count", Label: "Log Retention Count", Description: "Number of recent log files to keep.", Type: "int"},
8082
},
8183
"Connections": {
8284
{Key: "max_connections_per_host", Label: "Max Connections/Host", Description: "Maximum concurrent connections per host (1-64).", Type: "int"},
@@ -123,6 +125,7 @@ func DefaultSettings() *Settings {
123125
MaxConcurrentDownloads: 3,
124126
ClipboardMonitor: true,
125127
Theme: ThemeAdaptive,
128+
LogRetentionCount: 5,
126129
},
127130
Connections: ConnectionSettings{
128131
MaxConnectionsPerHost: 32,

‎internal/engine/concurrent/downloader.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ func (d *ConcurrentDownloader) Download(ctx context.Context, rawurl, destPath st
219219
// Robustness: ensure state counter starts at 0 for fresh download
220220
if d.State != nil {
221221
d.State.Downloaded.Store(0)
222+
d.State.SyncSessionStart()
222223
}
223224
}
224225
queue := NewTaskQueue()

‎internal/tui/settings_view.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ func (m RootModel) getSettingsValues(category string) map[string]interface{} {
219219
values["max_concurrent_downloads"] = m.Settings.General.MaxConcurrentDownloads
220220
values["clipboard_monitor"] = m.Settings.General.ClipboardMonitor
221221
values["theme"] = m.Settings.General.Theme
222+
values["log_retention_count"] = m.Settings.General.LogRetentionCount
222223

223224
case "Connections":
224225
values["max_connections_per_host"] = m.Settings.Connections.MaxConnectionsPerHost
@@ -313,6 +314,13 @@ func (m *RootModel) setGeneralSetting(key, value, typ string) error {
313314
}
314315
m.Settings.General.Theme = theme
315316
m.ApplyTheme(theme)
317+
case "log_retention_count":
318+
if v, err := strconv.Atoi(value); err == nil {
319+
if v < 0 {
320+
v = 0 // Minimum valid value
321+
}
322+
m.Settings.General.LogRetentionCount = v
323+
}
316324
}
317325
return nil
318326
}
@@ -571,6 +579,8 @@ func (m *RootModel) resetSettingToDefault(category, key string, defaults *config
571579
m.Settings.General.ClipboardMonitor = defaults.General.ClipboardMonitor
572580
case "theme":
573581
m.Settings.General.Theme = defaults.General.Theme
582+
case "log_retention_count":
583+
m.Settings.General.LogRetentionCount = defaults.General.LogRetentionCount
574584
}
575585

576586
case "Connections":

‎internal/utils/debug.go‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ package utils
22

33
import (
44
"fmt"
5+
"io/fs"
56
"os"
67
"path/filepath"
8+
"sort"
9+
"strings"
710
"sync"
811
"time"
912
)
@@ -45,3 +48,50 @@ func Debug(format string, args ...any) {
4548
fmt.Fprintf(debugFile, "[%s] %s\n", timestamp, fmt.Sprintf(format, args...))
4649
}
4750
}
51+
52+
// CleanupLogs removes old log files, keeping only the most recent retentionCount files
53+
func CleanupLogs(retentionCount int) {
54+
if retentionCount < 0 {
55+
return // Keep all logs
56+
}
57+
58+
mu.RLock()
59+
dir := logsDir
60+
mu.RUnlock()
61+
62+
if dir == "" {
63+
return
64+
}
65+
66+
entries, err := os.ReadDir(dir)
67+
if err != nil {
68+
// If directory doesn't exist, nothing to clean
69+
return
70+
}
71+
72+
var logs []fs.DirEntry
73+
for _, entry := range entries {
74+
if !entry.IsDir() && strings.HasPrefix(entry.Name(), "debug-") && strings.HasSuffix(entry.Name(), ".log") {
75+
logs = append(logs, entry)
76+
}
77+
}
78+
79+
// Sort by modification time (newest first)
80+
// Filenames have timestamp: debug-YYYYMMDD-HHMMSS.log, so alphabetical sort is also chronological
81+
// But let's rely on ModTime to be safe if possible, or just name since it is consistent
82+
sort.Slice(logs, func(i, j int) bool {
83+
// Newest first
84+
// Since names are YYYYMMDD-HHMMSS, reverse alphabetical works
85+
return logs[i].Name() > logs[j].Name()
86+
})
87+
88+
if len(logs) <= retentionCount {
89+
return
90+
}
91+
92+
// Remove older logs
93+
for _, log := range logs[retentionCount:] {
94+
path := filepath.Join(dir, log.Name())
95+
os.Remove(path)
96+
}
97+
}

‎internal/utils/debug_test.go‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package utils
22

33
import (
4+
"fmt"
45
"os"
56
"path/filepath"
67
"strings"
@@ -87,3 +88,76 @@ func TestLogFilePath(t *testing.T) {
8788
t.Errorf("Logs directory should be absolute path, got: %s", logsDir)
8889
}
8990
}
91+
92+
func TestCleanupLogs(t *testing.T) {
93+
// Use a temporary directory for this test
94+
tempDir, err := os.MkdirTemp("", "surge-logs-test")
95+
if err != nil {
96+
t.Fatalf("Failed to create temp dir: %v", err)
97+
}
98+
defer os.RemoveAll(tempDir)
99+
100+
// Configure debug to use this temp dir
101+
ConfigureDebug(tempDir)
102+
103+
// Reset configuration after test (though this changes global state, so might affect other tests potentially)
104+
// But in these unit tests, parallelism isn't enabled by default.
105+
defer ConfigureDebug(config.GetLogsDir())
106+
107+
// Create 10 dummy log files
108+
baseTime := time.Now()
109+
for i := 0; i < 10; i++ {
110+
// Use file name format matching debug.go: debug-YYYYMMDD-HHMMSS.log
111+
// We add 'i' to time to ensure uniqueness and order
112+
ts := baseTime.Add(time.Duration(i) * time.Hour)
113+
filename := fmt.Sprintf("debug-%s.log", ts.Format("20060102-150405"))
114+
path := filepath.Join(tempDir, filename)
115+
116+
err := os.WriteFile(path, []byte("dummy log"), 0644)
117+
if err != nil {
118+
t.Fatalf("Failed to write dummy log: %v", err)
119+
}
120+
}
121+
122+
// Verify we created 10
123+
entries, err := os.ReadDir(tempDir)
124+
if err != nil {
125+
t.Fatalf("Failed to read dir: %v", err)
126+
}
127+
if len(entries) != 10 {
128+
t.Fatalf("Expected 10 files, got %d", len(entries))
129+
}
130+
131+
// Test cleanup: Keep 5
132+
CleanupLogs(5)
133+
134+
// Verify we have 5 left
135+
entries, err = os.ReadDir(tempDir)
136+
if err != nil {
137+
t.Fatalf("Failed to read dir after cleanup: %v", err)
138+
}
139+
140+
if len(entries) != 5 {
141+
// For debugging failure
142+
var names []string
143+
for _, e := range entries {
144+
names = append(names, e.Name())
145+
}
146+
t.Errorf("Expected 5 files, got %d. Files: %v", len(entries), names)
147+
}
148+
149+
// Verify we kept the NEWEST ones (indices 5, 6, 7, 8, 9 from loop)
150+
// The file created with i=9 should be present
151+
newestTS := baseTime.Add(9 * time.Hour).Format("20060102-150405")
152+
expectedName := fmt.Sprintf("debug-%s.log", newestTS)
153+
found := false
154+
for _, e := range entries {
155+
if e.Name() == expectedName {
156+
found = true
157+
break
158+
}
159+
}
160+
if !found {
161+
t.Errorf("Expected newest file %s to be present, but it was not", expectedName)
162+
}
163+
}

0 commit comments

Comments
 (0)