From f4d57f36df3c776944f8a94dbf7a2fd39508436c Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Thu, 21 May 2026 11:16:41 +0530 Subject: [PATCH 01/10] remove redundant test --- tests/test_analyze.py | 90 ------------------------------------------- 1 file changed, 90 deletions(-) delete mode 100644 tests/test_analyze.py diff --git a/tests/test_analyze.py b/tests/test_analyze.py deleted file mode 100644 index 364e7006..00000000 --- a/tests/test_analyze.py +++ /dev/null @@ -1,90 +0,0 @@ -import unittest -from datetime import datetime - -import analyze - - -class AnalyzeTests(unittest.TestCase): - def test_parse_duration_mixed_units(self): - self.assertAlmostEqual(analyze.parse_duration("1m30s"), 90.0) - self.assertAlmostEqual(analyze.parse_duration("500ms"), 0.5) - self.assertAlmostEqual(analyze.parse_duration("2.5s"), 2.5) - - def test_percentile(self): - values = [1.0, 2.0, 3.0, 4.0] - self.assertAlmostEqual(analyze.percentile(values, 0.5), 2.5) - self.assertAlmostEqual(analyze.percentile(values, 0.95), 3.85) - - def test_filter_data_worker_and_time(self): - t1 = analyze.Task(datetime(2026, 2, 15, 10, 0, 1), 0, analyze.KB, 1.0) - t2 = analyze.Task(datetime(2026, 2, 15, 10, 0, 2), analyze.KB, analyze.KB, 1.0) - w1 = analyze.WorkerStats(worker_id=1, start_time=datetime(2026, 2, 15, 10, 0, 0), end_time=datetime(2026, 2, 15, 10, 0, 3), tasks=[t1]) - w2 = analyze.WorkerStats(worker_id=2, start_time=datetime(2026, 2, 15, 10, 0, 0), end_time=datetime(2026, 2, 15, 10, 0, 3), tasks=[t2]) - - data = { - "workers": {1: w1, 2: w2}, - "balancer_splits": [], - "health_kills": [], - "download_info": {}, - } - - filtered = analyze.filter_data( - data, - worker_filter=2, - since=datetime(2026, 2, 15, 10, 0, 1), - until=datetime(2026, 2, 15, 10, 0, 2), - ) - - self.assertEqual(list(filtered["workers"].keys()), [2]) - self.assertEqual(len(filtered["workers"][2].tasks), 1) - - def test_throughput_buckets(self): - tasks = [ - analyze.Task(datetime(2026, 2, 15, 10, 0, 0), 0, 2 * analyze.MB, 1.0), - analyze.Task(datetime(2026, 2, 15, 10, 0, 1), 2 * analyze.MB, 2 * analyze.MB, 1.0), - analyze.Task(datetime(2026, 2, 15, 10, 0, 3), 4 * analyze.MB, 2 * analyze.MB, 1.0), - ] - worker = analyze.WorkerStats(worker_id=1, tasks=tasks) - ctx = analyze.ReportContext( - data={"workers": {1: worker}, "balancer_splits": [], "health_kills": [], "download_info": {}}, - workers=[worker], - all_tasks=tasks, - global_avg_speed=0.0, - global_avg_task_duration=0.0, - speed_by_worker={1: worker.avg_speed_mbps}, - slow_tasks=[], - ) - - buckets = analyze.build_throughput_buckets(ctx, 2) - self.assertEqual(len(buckets), 2) - self.assertEqual(buckets[0]["bytes"], 4 * analyze.MB) - self.assertEqual(buckets[1]["bytes"], 2 * analyze.MB) - - def test_health_event_impact(self): - tasks = [ - analyze.Task(datetime(2026, 2, 15, 10, 0, 5), 0, 2 * analyze.MB, 2.0), - analyze.Task(datetime(2026, 2, 15, 10, 0, 15), 2 * analyze.MB, 2 * analyze.MB, 1.0), - ] - worker = analyze.WorkerStats(worker_id=1, tasks=tasks) - ctx = analyze.ReportContext( - data={ - "workers": {1: worker}, - "balancer_splits": [], - "health_kills": [(datetime(2026, 2, 15, 10, 0, 10), 1, "slow")], - "download_info": {}, - }, - workers=[worker], - all_tasks=tasks, - global_avg_speed=0.0, - global_avg_task_duration=0.0, - speed_by_worker={1: worker.avg_speed_mbps}, - slow_tasks=[], - ) - - impacts = analyze.compute_health_event_impact(ctx, 10) - self.assertEqual(len(impacts), 1) - self.assertGreater(impacts[0]["after_avg_speed_mbps"], impacts[0]["before_avg_speed_mbps"]) - - -if __name__ == "__main__": - unittest.main() From 7630784e385be02ddedde292e8b5f31800d0e180 Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Thu, 21 May 2026 11:35:53 +0530 Subject: [PATCH 02/10] refactor: rename buildPoolIsNameActive to buildActiveDownloadChecker for better clarity --- cmd/root.go | 8 ++++++-- cmd/root_lifecycle_test.go | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 97148cb6..a41708b3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -87,7 +87,11 @@ var ( globalEnqueueMu sync.Mutex ) -func buildPoolIsNameActive(getAll func() []types.DownloadConfig) processing.IsNameActiveFunc { +// buildActiveDownloadChecker constructs an IsNameActiveFunc callback used by the +// lifecycle manager to detect file collisions with in-flight downloads. It queries +// the provided getAll callback to check if any active download is writing to the +// target directory and filename. +func buildActiveDownloadChecker(getAll func() []types.DownloadConfig) processing.IsNameActiveFunc { if getAll == nil { return nil } @@ -138,7 +142,7 @@ func newLocalLifecycleManager(service core.DownloadService, getAll func() []type addWithIDFunc = service.AddWithID } - return processing.NewLifecycleManager(addFunc, addWithIDFunc, buildPoolIsNameActive(getAll)) + return processing.NewLifecycleManager(addFunc, addWithIDFunc, buildActiveDownloadChecker(getAll)) } func startLifecycleEventWorker(service core.DownloadService, mgr *processing.LifecycleManager) (func(), error) { diff --git a/cmd/root_lifecycle_test.go b/cmd/root_lifecycle_test.go index d458e166..ef9847a5 100644 --- a/cmd/root_lifecycle_test.go +++ b/cmd/root_lifecycle_test.go @@ -72,7 +72,7 @@ func (s *countingLifecycleService) StreamEvents(context.Context) (<-chan interfa return ch, cleanup, nil } -func TestBuildPoolIsNameActive(t *testing.T) { +func TestBuildActiveDownloadChecker(t *testing.T) { getAll := func() []types.DownloadConfig { state := types.NewProgressState("dl-2", 0) state.SetFilename("from-state.iso") @@ -85,7 +85,7 @@ func TestBuildPoolIsNameActive(t *testing.T) { } } - isNameActive := buildPoolIsNameActive(getAll) + isNameActive := buildActiveDownloadChecker(getAll) if isNameActive == nil { t.Fatal("expected name activity callback") } From dc86a2a53a9b768bb38e05f262a48764eb5bc9cb Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Thu, 21 May 2026 12:13:23 +0530 Subject: [PATCH 03/10] refactor: migrate settings to a schema-driven structure using wrapper types for enhanced validation and restart tracking --- cmd/autoresume_test.go | 4 +- cmd/connect_test.go | 4 +- cmd/headless_approval_test.go | 8 +- cmd/http_api.go | 2 +- cmd/http_api_test.go | 4 +- cmd/http_handler_test.go | 8 +- cmd/root.go | 2 +- cmd/root_downloads.go | 18 +- cmd/root_lifecycle_test.go | 12 +- cmd/root_startup.go | 4 +- cmd/service_ui_std.go | 2 +- cmd/startup_test.go | 2 +- .../config/config_warning_regression_test.go | 39 +- internal/config/settings.go | 1169 +++++++++++------ internal/config/settings_schema.go | 34 + internal/config/settings_test.go | 722 ++-------- internal/core/local_service.go | 6 +- .../core/pause_resume_integration_test.go | 6 +- internal/engine/types/accuracy_test.go | 2 +- internal/processing/events.go | 6 +- internal/processing/events_internal_test.go | 8 +- internal/processing/file_utils.go | 4 +- internal/processing/file_utils_test.go | 10 +- internal/processing/manager.go | 4 +- internal/processing/manager_test.go | 14 +- internal/processing/pause_resume.go | 4 +- internal/processing/probe_test.go | 2 +- internal/tui/autoresume_test.go | 6 +- internal/tui/category_regressions_test.go | 22 +- internal/tui/helpers.go | 63 +- internal/tui/model.go | 10 +- internal/tui/process.go | 4 +- internal/tui/resume_lifecycle_test.go | 6 +- internal/tui/settings_reset_test.go | 44 +- internal/tui/settings_restart_test.go | 8 +- internal/tui/startup_test.go | 2 +- internal/tui/update_category.go | 4 +- internal/tui/update_dashboard.go | 8 +- internal/tui/update_events.go | 4 +- internal/tui/update_filepicker.go | 4 +- internal/tui/update_input.go | 2 +- internal/tui/update_modals.go | 4 +- internal/tui/update_settings.go | 12 +- internal/tui/update_test.go | 12 +- internal/tui/view_category.go | 2 +- internal/tui/view_settings.go | 277 ++-- 46 files changed, 1250 insertions(+), 1344 deletions(-) create mode 100644 internal/config/settings_schema.go diff --git a/cmd/autoresume_test.go b/cmd/autoresume_test.go index ebc19d7b..33272320 100644 --- a/cmd/autoresume_test.go +++ b/cmd/autoresume_test.go @@ -41,8 +41,8 @@ func TestCmd_AutoResume_Execution(t *testing.T) { // 2. Settings with AutoResume = true settingsPath := filepath.Join(surgeDir, "settings.json") settings := config.DefaultSettings() - settings.General.AutoResume = true - settings.General.DefaultDownloadDir = tmpDir + settings.General.AutoResume.Value = true + settings.General.DefaultDownloadDir.Value = tmpDir data, _ := json.Marshal(settings) if err := os.WriteFile(settingsPath, data, 0o644); err != nil { diff --git a/cmd/connect_test.go b/cmd/connect_test.go index 1d8ba701..3a037625 100644 --- a/cmd/connect_test.go +++ b/cmd/connect_test.go @@ -84,8 +84,8 @@ func TestNewRemoteRootModel_UsesNilOrchestrator(t *testing.T) { func TestNewRemoteRootModel_DownloadRequestUsesServiceAdd(t *testing.T) { service := &fakeRemoteDownloadService{} m := newRemoteRootModel("https://example.com:1700", service) - m.Settings.Extension.ExtensionPrompt = false - m.Settings.General.WarnOnDuplicate = false + m.Settings.Extension.ExtensionPrompt.Value = false + m.Settings.General.WarnOnDuplicate.Value = false updated, cmd := m.Update(events.DownloadRequestMsg{ URL: "https://example.com/file.bin", diff --git a/cmd/headless_approval_test.go b/cmd/headless_approval_test.go index 7d3c5184..1e9c9e2f 100644 --- a/cmd/headless_approval_test.go +++ b/cmd/headless_approval_test.go @@ -36,7 +36,7 @@ func TestHandleDownload_HeadlessMode_AutoApprovesNonDuplicate(t *testing.T) { // Enable ExtensionPrompt (default is true, but let's be explicit) settings := config.DefaultSettings() - settings.Extension.ExtensionPrompt = true + settings.Extension.ExtensionPrompt.Value = true if err := config.SaveSettings(settings); err != nil { t.Fatalf("SaveSettings failed: %v", err) } @@ -94,7 +94,7 @@ func TestHandleDownload_HeadlessMode_RejectsDuplicateWithWarn(t *testing.T) { // Enable WarnOnDuplicate settings := config.DefaultSettings() - settings.General.WarnOnDuplicate = true + settings.General.WarnOnDuplicate.Value = true if err := config.SaveSettings(settings); err != nil { t.Fatalf("SaveSettings failed: %v", err) } @@ -145,8 +145,8 @@ func TestHandleDownload_HeadlessMode_RejectsExtensionPromptDuplicate(t *testing. }) settings := config.DefaultSettings() - settings.Extension.ExtensionPrompt = true - settings.General.WarnOnDuplicate = false + settings.Extension.ExtensionPrompt.Value = true + settings.General.WarnOnDuplicate.Value = false if err := config.SaveSettings(settings); err != nil { t.Fatalf("SaveSettings failed: %v", err) } diff --git a/cmd/http_api.go b/cmd/http_api.go index 2a1e51df..be7f1024 100644 --- a/cmd/http_api.go +++ b/cmd/http_api.go @@ -291,7 +291,7 @@ func ensureOpenActionRequestAllowed(r *http.Request) error { } settings := getSettings() - if settings != nil && settings.General.AllowRemoteOpenActions { + if settings != nil && settings.General.AllowRemoteOpenActions.AsBool() { return nil } diff --git a/cmd/http_api_test.go b/cmd/http_api_test.go index 079a1c04..5377b2eb 100644 --- a/cmd/http_api_test.go +++ b/cmd/http_api_test.go @@ -110,7 +110,7 @@ func TestEnsureOpenActionRequestAllowed_RemoteToggle(t *testing.T) { } globalSettings = config.DefaultSettings() - globalSettings.General.AllowRemoteOpenActions = true + globalSettings.General.AllowRemoteOpenActions.Value = true if err := ensureOpenActionRequestAllowed(request); err != nil { t.Fatalf("expected remote open action to be allowed when enabled, got: %v", err) } @@ -380,7 +380,7 @@ func TestEnsureOpenActionRequestAllowed_ForwardedLoopbackDenied(t *testing.T) { } globalSettings = config.DefaultSettings() - globalSettings.General.AllowRemoteOpenActions = true + globalSettings.General.AllowRemoteOpenActions.Value = true if err := ensureOpenActionRequestAllowed(request); err != nil { t.Fatalf("expected forwarded loopback request to be allowed when enabled, got: %v", err) } diff --git a/cmd/http_handler_test.go b/cmd/http_handler_test.go index 51af141a..89218f31 100644 --- a/cmd/http_handler_test.go +++ b/cmd/http_handler_test.go @@ -71,8 +71,8 @@ func TestHandleDownload_PathResolution(t *testing.T) { // Create a temporary settings file settings := config.DefaultSettings() - settings.General.DefaultDownloadDir = defaultDownloadDir - settings.Extension.ExtensionPrompt = false + settings.General.DefaultDownloadDir.Value = defaultDownloadDir + settings.Extension.ExtensionPrompt.Value = false if err := config.SaveSettings(settings); err != nil { t.Fatal(err) @@ -442,8 +442,8 @@ func TestHandleDownload_PublishError_RecordsPreflightError(t *testing.T) { t.Cleanup(func() { serverProgram = origServerProgram }) settings := config.DefaultSettings() - settings.Extension.ExtensionPrompt = true - settings.General.WarnOnDuplicate = false + settings.Extension.ExtensionPrompt.Value = true + settings.General.WarnOnDuplicate.Value = false if err := config.SaveSettings(settings); err != nil { t.Fatalf("SaveSettings failed: %v", err) } diff --git a/cmd/root.go b/cmd/root.go index a41708b3..39d09640 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -441,7 +441,7 @@ var rootCmd = &cobra.Command{ PersistentPreRun: func(cmd *cobra.Command, args []string) { GlobalProgressCh = make(chan any, 100) globalSettings = getSettings() - GlobalPool = download.NewWorkerPool(GlobalProgressCh, globalSettings.Network.MaxConcurrentDownloads) + GlobalPool = download.NewWorkerPool(GlobalProgressCh, globalSettings.Network.MaxConcurrentDownloads.AsInt()) }, RunE: func(cmd *cobra.Command, args []string) error { if ranRemote, err := maybeRunRemoteTUI(cmd, args); err != nil { diff --git a/cmd/root_downloads.go b/cmd/root_downloads.go index 0dc67e34..b25a6ab5 100644 --- a/cmd/root_downloads.go +++ b/cmd/root_downloads.go @@ -201,7 +201,7 @@ func maybeRequireDownloadApproval(w http.ResponseWriter, service core.DownloadSe return false } - shouldPrompt := resolved.settings.Extension.ExtensionPrompt || (resolved.settings.General.WarnOnDuplicate && resolved.isDuplicate) + shouldPrompt := resolved.settings.Extension.ExtensionPrompt.AsBool() || (resolved.settings.General.WarnOnDuplicate.AsBool() && resolved.isDuplicate) if !shouldPrompt { return false } @@ -325,7 +325,7 @@ func processDownloads(urls []string, outputDir string, port int) int { outPath = utils.EnsureAbsPath(outPath) // CLI explicit arg means we do not auto-route when user provided an explicit output path. - isExplicit := isExplicitOutputPath(outPath, settings.General.DefaultDownloadDir) + isExplicit := isExplicitOutputPath(outPath, settings.General.DefaultDownloadDir.AsString()) if lifecycle == nil { err := fmt.Errorf("lifecycle manager unavailable") recordPreflightDownloadError(url, outPath, err) @@ -358,7 +358,7 @@ func resolveOutputDir(reqPath string, relativeToDefaultDir bool, defaultOutputDi } if relativeToDefaultDir && reqPath != "" { - baseDir := settings.General.DefaultDownloadDir + baseDir := settings.General.DefaultDownloadDir.AsString() if baseDir == "" { baseDir = defaultOutputDir } @@ -369,8 +369,8 @@ func resolveOutputDir(reqPath string, relativeToDefaultDir bool, defaultOutputDi } else if outPath == "" { if defaultOutputDir != "" { outPath = defaultOutputDir - } else if settings.General.DefaultDownloadDir != "" { - outPath = settings.General.DefaultDownloadDir + } else if settings.General.DefaultDownloadDir.AsString() != "" { + outPath = settings.General.DefaultDownloadDir.AsString() } else { outPath = "." } @@ -387,16 +387,16 @@ func mapClientWindowsPath(reqPath string, relativeToDefaultDir bool, defaultOutp baseDir := "." if relativeToDefaultDir { - if settings != nil && strings.TrimSpace(settings.General.DefaultDownloadDir) != "" { - baseDir = settings.General.DefaultDownloadDir + if settings != nil && strings.TrimSpace(settings.General.DefaultDownloadDir.AsString()) != "" { + baseDir = settings.General.DefaultDownloadDir.AsString() } else if strings.TrimSpace(defaultOutputDir) != "" { baseDir = defaultOutputDir } } else { if strings.TrimSpace(defaultOutputDir) != "" { baseDir = defaultOutputDir - } else if settings != nil && strings.TrimSpace(settings.General.DefaultDownloadDir) != "" { - baseDir = settings.General.DefaultDownloadDir + } else if settings != nil && strings.TrimSpace(settings.General.DefaultDownloadDir.AsString()) != "" { + baseDir = settings.General.DefaultDownloadDir.AsString() } } diff --git a/cmd/root_lifecycle_test.go b/cmd/root_lifecycle_test.go index ef9847a5..6a75d367 100644 --- a/cmd/root_lifecycle_test.go +++ b/cmd/root_lifecycle_test.go @@ -239,8 +239,8 @@ func TestProcessDownloads_RoutesBinFilesToCustomCategory(t *testing.T) { t.Fatalf("MkdirAll failed: %v", err) } settings := config.DefaultSettings() - settings.General.DefaultDownloadDir = defaultDir - settings.Categories.CategoryEnabled = true + settings.General.DefaultDownloadDir.Value = defaultDir + settings.Categories.CategoryEnabled.Value = true settings.Categories.Categories = append(settings.Categories.Categories, config.Category{ Name: "Binary", Pattern: `(?i)\.bin$`, @@ -325,8 +325,8 @@ func TestProcessDownloads_UsesLatestSavedCategorySettings(t *testing.T) { defaultDir := t.TempDir() initial := config.DefaultSettings() - initial.General.DefaultDownloadDir = defaultDir - initial.Categories.CategoryEnabled = false + initial.General.DefaultDownloadDir.Value = defaultDir + initial.Categories.CategoryEnabled.Value = false if err := config.SaveSettings(initial); err != nil { t.Fatalf("SaveSettings(initial) failed: %v", err) } @@ -343,8 +343,8 @@ func TestProcessDownloads_UsesLatestSavedCategorySettings(t *testing.T) { t.Fatalf("MkdirAll failed: %v", err) } updated := config.DefaultSettings() - updated.General.DefaultDownloadDir = defaultDir - updated.Categories.CategoryEnabled = true + updated.General.DefaultDownloadDir.Value = defaultDir + updated.Categories.CategoryEnabled.Value = true updated.Categories.Categories = []config.Category{ { Name: "Binary", diff --git a/cmd/root_startup.go b/cmd/root_startup.go index 825261c3..5ade05a0 100644 --- a/cmd/root_startup.go +++ b/cmd/root_startup.go @@ -52,7 +52,7 @@ func initializeGlobalState() error { utils.ConfigureDebug(logsDir) // Clean up old logs (keeping retention-1 because a new log will be created immediately after) - retention := getSettings().General.LogRetentionCount + retention := getSettings().General.LogRetentionCount.AsInt() if retention > 0 { utils.CleanupLogs(retention - 1) } else { @@ -83,7 +83,7 @@ func resumePausedDownloads() { for _, entry := range pausedEntries { // If entry is explicitly queued, we should start it regardless of AutoResume setting // If entry is paused, we only start it if AutoResume is enabled - if entry.Status == "paused" && !settings.General.AutoResume { + if entry.Status == "paused" && !settings.General.AutoResume.AsBool() { continue } if GlobalService == nil || entry.ID == "" { diff --git a/cmd/service_ui_std.go b/cmd/service_ui_std.go index 4822c62b..2443b0e2 100644 --- a/cmd/service_ui_std.go +++ b/cmd/service_ui_std.go @@ -16,7 +16,7 @@ func configureServiceUI(m *tui.RootModel) { } status, statusErr := s.Status() if statusErr == nil { - m.Settings.General.AutoStart = (status == service.StatusRunning || status == service.StatusStopped) + m.Settings.General.AutoStart.Value = (status == service.StatusRunning || status == service.StatusStopped) } m.ToggleServiceFunc = func(enable bool) error { diff --git a/cmd/startup_test.go b/cmd/startup_test.go index f4688b00..823835f2 100644 --- a/cmd/startup_test.go +++ b/cmd/startup_test.go @@ -128,7 +128,7 @@ func setupTestEnv(t *testing.T, tmpDir string) { // Setup Settings (AutoResume=false default) settings := config.DefaultSettings() - settings.General.AutoResume = false // Ensure we test that "queued" overrides this + settings.General.AutoResume.Value = false // Ensure we test that "queued" overrides this if err := config.SaveSettings(settings); err != nil { t.Fatal(err) } diff --git a/internal/config/config_warning_regression_test.go b/internal/config/config_warning_regression_test.go index 769cbb44..b601770f 100644 --- a/internal/config/config_warning_regression_test.go +++ b/internal/config/config_warning_regression_test.go @@ -2,11 +2,6 @@ package config // Regression tests for: config problems must be surfaced in StartupWarnings, // never silently swallowed. -// -// Root causes fixed (branch fix-config-fails): -// 1. Corrupt settings.json returned DefaultSettings() with no warning at all. -// 2. publishStartupWarnings() fired before the TUI event stream was connected, -// so valid warnings were silently dropped. import ( "os" @@ -127,31 +122,31 @@ func TestValidate_InvalidField_PopulatesStartupWarnings(t *testing.T) { }{ { name: "MaxConnectionsPerHost out of range", - mutate: func(s *Settings) { s.Network.MaxConnectionsPerHost = 999 }, + mutate: func(s *Settings) { s.Network.MaxConnectionsPerDownload.Value = 999 }, }, { name: "MaxConcurrentDownloads out of range", - mutate: func(s *Settings) { s.Network.MaxConcurrentDownloads = 99 }, + mutate: func(s *Settings) { s.Network.MaxConcurrentDownloads.Value = 99 }, }, { name: "MaxTaskRetries out of range", - mutate: func(s *Settings) { s.Performance.MaxTaskRetries = 999 }, + mutate: func(s *Settings) { s.Performance.MaxTaskRetries.Value = 999 }, }, { name: "SlowWorkerThreshold out of range", - mutate: func(s *Settings) { s.Performance.SlowWorkerThreshold = 5.0 }, + mutate: func(s *Settings) { s.Performance.SlowWorkerThreshold.Value = 5.0 }, }, { name: "LogRetentionCount out of range", - mutate: func(s *Settings) { s.General.LogRetentionCount = 0 }, + mutate: func(s *Settings) { s.General.LogRetentionCount.Value = 0 }, }, { name: "Invalid proxy URL", - mutate: func(s *Settings) { s.Network.ProxyURL = "not-a-url" }, + mutate: func(s *Settings) { s.Network.ProxyURL.Value = "not-a-url" }, }, { name: "Invalid DNS server", - mutate: func(s *Settings) { s.Network.CustomDNS = "not.a.valid.ip.server.!!!" }, + mutate: func(s *Settings) { s.Network.CustomDNS.Value = "not.a.valid.ip.server.!!!" }, }, } @@ -172,9 +167,9 @@ func TestValidate_InvalidField_PopulatesStartupWarnings(t *testing.T) { // field independently contributes a warning (no short-circuiting). func TestValidate_MultipleInvalidFields_AllWarningsPresent(t *testing.T) { s := DefaultSettings() - s.Network.MaxConnectionsPerHost = 999 // invalid - s.Network.MaxConcurrentDownloads = 99 // invalid - s.Performance.SlowWorkerThreshold = -1 // invalid + s.Network.MaxConnectionsPerDownload.Value = 999 // invalid + s.Network.MaxConcurrentDownloads.Value = 99 // invalid + s.Performance.SlowWorkerThreshold.Value = -1.0 // invalid s.Validate() if len(s.StartupWarnings) < 3 { @@ -188,7 +183,7 @@ func TestValidate_MultipleInvalidFields_AllWarningsPresent(t *testing.T) { // on already-reset settings produces zero warnings rather than accumulating. func TestValidate_ClearsOldWarningsOnRevalidation(t *testing.T) { s := DefaultSettings() - s.Network.MaxConnectionsPerHost = 999 // invalid — will be reset to default + s.Network.MaxConnectionsPerDownload.Value = 999 // invalid — will be reset to default s.Validate() firstCount := len(s.StartupWarnings) @@ -196,7 +191,7 @@ func TestValidate_ClearsOldWarningsOnRevalidation(t *testing.T) { t.Fatal("expected at least one warning on first Validate()") } - // After Validate(), MaxConnectionsPerHost has been reset to the default (valid). + // After Validate(), MaxConnectionsPerDownload has been reset to the default (valid). // A second Validate() should find nothing wrong and produce zero warnings. // This confirms that warnings are cleared and not accumulated across calls. s.Validate() @@ -227,12 +222,12 @@ func TestLoadSettings_CorruptJSON_ReturnsDefaultValues(t *testing.T) { if settings == nil { t.Fatal("LoadSettings returned nil") } - if settings.Network.MaxConnectionsPerHost != defaults.Network.MaxConnectionsPerHost { - t.Errorf("MaxConnectionsPerHost = %d, want default %d", - settings.Network.MaxConnectionsPerHost, defaults.Network.MaxConnectionsPerHost) + if settings.Network.MaxConnectionsPerDownload.AsInt() != defaults.Network.MaxConnectionsPerDownload.AsInt() { + t.Errorf("MaxConnectionsPerDownload = %d, want default %d", + settings.Network.MaxConnectionsPerDownload.AsInt(), defaults.Network.MaxConnectionsPerDownload.AsInt()) } - if settings.Performance.MaxTaskRetries != defaults.Performance.MaxTaskRetries { + if settings.Performance.MaxTaskRetries.AsInt() != defaults.Performance.MaxTaskRetries.AsInt() { t.Errorf("MaxTaskRetries = %d, want default %d", - settings.Performance.MaxTaskRetries, defaults.Performance.MaxTaskRetries) + settings.Performance.MaxTaskRetries.AsInt(), defaults.Performance.MaxTaskRetries.AsInt()) } } diff --git a/internal/config/settings.go b/internal/config/settings.go index d2738b00..7ecd3bc4 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -7,7 +7,6 @@ import ( "net/url" "os" "path/filepath" - "reflect" "strings" "time" @@ -15,246 +14,226 @@ import ( "github.com/SurgeDM/Surge/internal/utils" ) -// Settings holds all user-configurable application settings organized by category. type Settings struct { - General GeneralSettings `json:"general" ui_label:"General"` - Network NetworkSettings `json:"network" ui_label:"Network"` - Performance PerformanceSettings `json:"performance" ui_label:"Performance"` - Categories CategorySettings `json:"categories" ui_label:"Categories"` - Extension ExtensionSettings `json:"extension" ui_label:"Extension"` - - // StartupWarnings holds validation messages from the most recent LoadSettings call. - // It is ignored during JSON serialization. + General GeneralSettings `json:"general"` + Network NetworkSettings `json:"network"` + Performance PerformanceSettings `json:"performance"` + Categories CategorySettings `json:"categories"` + Extension ExtensionSettings `json:"extension"` + + // Schema-driven categories list populated on initialization + CategoriesList []*SettingsCategory `json:"-"` + StartupWarnings []string `json:"-"` } -// GeneralSettings contains application behavior settings. type GeneralSettings struct { - DefaultDownloadDir string `json:"default_download_dir" ui_label:"Default Download Dir" ui_desc:"Default directory for new downloads. Leave empty to use current directory."` - WarnOnDuplicate bool `json:"warn_on_duplicate" ui_label:"Warn on Duplicate" ui_desc:"Show warning when adding a download that already exists."` - DownloadCompleteNotification bool `json:"download_complete_notification" ui_label:"Download Complete Notification" ui_desc:"Show system notification when a download finishes."` - AllowRemoteOpenActions bool `json:"allow_remote_open_actions" ui_label:"Allow Remote Open Actions" ui_desc:"Allow /open-file and /open-folder API calls from non-loopback clients. Disabled by default for security." ui_restart:"true"` - AutoResume bool `json:"auto_resume" ui_label:"Auto Resume" ui_desc:"Automatically resume paused downloads on startup." ui_restart:"true"` - AutoStart bool `json:"auto_start" ui_label:"Automatic Startup" ui_desc:"Start Surge automatically when the system boots (requires service installation)."` - SkipUpdateCheck bool `json:"skip_update_check" ui_label:"Skip Update Check" ui_desc:"Disable automatic check for new versions on startup." ui_restart:"true"` - - ClipboardMonitor bool `json:"clipboard_monitor" ui_label:"Clipboard Monitor" ui_desc:"Watch clipboard for URLs and prompt to download them." ui_restart:"true"` - Theme int `json:"theme" ui_label:"App Theme" ui_desc:"UI Theme (System, Light, Dark)."` - ThemePath string `json:"theme_path" ui_label:"Theme File" ui_desc:"Path to a custom .toml color scheme."` - LogRetentionCount int `json:"log_retention_count" ui_label:"Log Retention Count" ui_desc:"Number of recent log files to keep." ui_restart:"true"` - LiveSpeedGraph bool `json:"live_speed_graph" ui_label:"Live Speed Graph" ui_desc:"Use live speed for graph instead of EMA smoothed speed."` + DefaultDownloadDir *Setting `json:"default_download_dir"` + WarnOnDuplicate *Setting `json:"warn_on_duplicate"` + DownloadCompleteNotification *Setting `json:"download_complete_notification"` + AllowRemoteOpenActions *Setting `json:"allow_remote_open_actions"` + AutoResume *Setting `json:"auto_resume"` + AutoStart *Setting `json:"auto_start"` + SkipUpdateCheck *Setting `json:"skip_update_check"` + ClipboardMonitor *Setting `json:"clipboard_monitor"` + Theme *Setting `json:"theme"` + ThemePath *Setting `json:"theme_path"` + LogRetentionCount *Setting `json:"log_retention_count"` + LiveSpeedGraph *Setting `json:"live_speed_graph"` } -const ( - ThemeAdaptive = 0 - ThemeLight = 1 - ThemeDark = 2 -) +type NetworkSettings struct { + MaxConnectionsPerDownload *Setting `json:"max_connections_per_host"` + // Kept for backward compatibility + MaxConnectionsPerHost int `json:"-"` + MaxConcurrentDownloads *Setting `json:"max_concurrent_downloads"` + MaxConcurrentProbes *Setting `json:"max_concurrent_probes"` + UserAgent *Setting `json:"user_agent"` + ProxyURL *Setting `json:"proxy_url"` + CustomDNS *Setting `json:"custom_dns"` + SequentialDownload *Setting `json:"sequential_download"` + MinChunkSize *Setting `json:"min_chunk_size"` + WorkerBufferSize *Setting `json:"worker_buffer_size"` + DialHedgeCount *Setting `json:"dial_hedge_count"` +} + +type PerformanceSettings struct { + MaxTaskRetries *Setting `json:"max_task_retries"` + SlowWorkerThreshold *Setting `json:"slow_worker_threshold"` + SlowWorkerGracePeriod *Setting `json:"slow_worker_grace_period"` + StallTimeout *Setting `json:"stall_timeout"` + SpeedEmaAlpha *Setting `json:"speed_ema_alpha"` +} -// CategorySettings holds options specifically for categorizing files. type CategorySettings struct { - CategoryEnabled bool `json:"category_enabled" ui_label:"Manage Categories" ui_desc:"Sort downloads into subfolders by file type. Press Enter to open Category Manager."` - Categories []Category `json:"categories" ui_ignored:"true"` + CategoryEnabled *Setting `json:"category_enabled"` + Categories []Category `json:"categories"` } -// ExtensionSettings contains settings for the browser extension. type ExtensionSettings struct { - ExtensionPrompt bool `json:"extension_prompt" ui_label:"Extension Prompt" ui_desc:"Prompt for confirmation when adding downloads via browser extension."` - ChromeExtensionURL string `json:"chrome_extension_url" ui_label:"Get Chrome Extension" ui_type:"link" ui_desc:"Open the Surge Chrome extension page."` - FirefoxExtensionURL string `json:"firefox_extension_url" ui_label:"Get Firefox Extension" ui_type:"link" ui_desc:"Open the Surge Firefox extension page."` - AuthToken string `json:"-" ui_label:"Auth Token" ui_type:"auth_token" ui_desc:"Your authentication token. Use this to connect the Browser Extension to Surge."` - InstructionsURL string `json:"instructions_url" ui_label:"Setup Instructions" ui_type:"link" ui_desc:"View detailed instructions on how to set up the Surge browser extension."` + ExtensionPrompt *Setting `json:"extension_prompt"` + ChromeExtensionURL *Setting `json:"chrome_extension_url"` + FirefoxExtensionURL *Setting `json:"firefox_extension_url"` + AuthToken *Setting `json:"auth_token"` + InstructionsURL *Setting `json:"instructions_url"` } -// NetworkSettings contains network connection parameters. -type NetworkSettings struct { - MaxConnectionsPerDownload int `json:"max_connections_per_host" ui_label:"Max Connections/Download" ui_desc:"Maximum concurrent connections per download (1-64)."` - // Deprecated: use MaxConnectionsPerDownload. - // Kept as a non-serialized compatibility alias for older code paths and tests. - MaxConnectionsPerHost int `json:"-" ui_ignored:"true"` - MaxConcurrentDownloads int `json:"max_concurrent_downloads" ui_label:"Max Concurrent Downloads" ui_desc:"Maximum number of downloads running at once (1-10)." ui_restart:"true"` - MaxConcurrentProbes int `json:"max_concurrent_probes" ui_label:"Max Concurrent Probes" ui_desc:"Maximum number of simultaneous server probes when adding many downloads at once (1-10)." ui_restart:"true"` - UserAgent string `json:"user_agent" ui_label:"User Agent" ui_desc:"Custom User-Agent string for HTTP requests. Leave empty for default."` - ProxyURL string `json:"proxy_url" ui_label:"Proxy URL" ui_desc:"HTTP/HTTPS proxy URL (e.g. http://127.0.0.1:1700). Leave empty to use system default."` - CustomDNS string `json:"custom_dns" ui_label:"Custom DNS Server" ui_desc:"Set custom DNS (e.g., 1.1.1.1:53, 94.140.14.14:53). Leave empty for system."` - SequentialDownload bool `json:"sequential_download" ui_label:"Sequential Download" ui_desc:"Download pieces in order (Streaming Mode). May be slower."` - MinChunkSize int64 `json:"min_chunk_size" ui_label:"Min Chunk Size" ui_desc:"Minimum download chunk size in MB (e.g., 2)."` - WorkerBufferSize int `json:"worker_buffer_size" ui_label:"Worker Buffer Size" ui_desc:"I/O buffer size per worker in KB (e.g., 512)."` - DialHedgeCount int `json:"dial_hedge_count" ui_label:"Dial Hedge Count" ui_desc:"Number of extra connections to dial pre-emptively to avoid slow connects (0-16)."` +// UnmarshalJSON updates only the Value field of the initialized pointer. +func (s *Setting) UnmarshalJSON(data []byte) error { + var val any + if err := json.Unmarshal(data, &val); err != nil { + return err + } + s.Value = val + return nil } -// PerformanceSettings contains performance tuning parameters. -type PerformanceSettings struct { - MaxTaskRetries int `json:"max_task_retries" ui_label:"Max Task Retries" ui_desc:"Number of times to retry a failed chunk before giving up."` - SlowWorkerThreshold float64 `json:"slow_worker_threshold" ui_label:"Slow Worker Threshold" ui_desc:"Restart workers slower than this fraction of mean speed (0.0-1.0, 0 disables relative slow-worker checks)."` - SlowWorkerGracePeriod time.Duration `json:"slow_worker_grace_period" ui_label:"Slow Worker Grace" ui_desc:"Grace period before checking worker speed (e.g., 5s, 0 checks immediately)."` - StallTimeout time.Duration `json:"stall_timeout" ui_label:"Stall Timeout" ui_desc:"Restart workers with no data for this duration (e.g., 5s, 0 disables stall detection)."` - SpeedEmaAlpha float64 `json:"speed_ema_alpha" ui_label:"Speed EMA Alpha" ui_desc:"Exponential moving average smoothing factor (0.0-1.0, 0 disables smoothing)."` +// MarshalJSON serializes only the primitive value of this setting. +func (s *Setting) MarshalJSON() ([]byte, error) { + return json.Marshal(s.Value) } -// SettingMeta provides metadata for a single setting (for UI rendering). -type SettingMeta struct { - Key string // JSON key name - Label string // Human-readable label - Description string // Help text displayed in right pane - Type string // "string", "int", "int64", "bool", "duration", "float64", "auth_token", "link" - RequiresRestart bool // Whether changing this setting requires an application restart +func (s *Setting) AsBool() bool { + if s == nil { + return false + } + if b, ok := s.Value.(bool); ok { + return b + } + return false } -// GetSettingsMetadata returns metadata for all settings organized by category. -func GetSettingsMetadata() map[string][]SettingMeta { - meta := make(map[string][]SettingMeta) - t := reflect.TypeOf(Settings{}) - - for i := 0; i < t.NumField(); i++ { - catField := t.Field(i) - catLabel := catField.Tag.Get("ui_label") - if catLabel == "" { - catLabel = catField.Name - } - - var catMetas []SettingMeta - catType := catField.Type - if catType.Kind() == reflect.Struct { - for j := 0; j < catType.NumField(); j++ { - settingField := catType.Field(j) - if settingField.Tag.Get("ui_ignored") == "true" { - continue - } - - key := settingField.Tag.Get("json") - if key == "" { - key = settingField.Name - } - - label := settingField.Tag.Get("ui_label") - if label == "" { - label = settingField.Name - } - - desc := settingField.Tag.Get("ui_desc") - - // Determine implicit Type - typStr := settingField.Tag.Get("ui_type") - if typStr == "" { - typStr = "string" - switch settingField.Type.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32: - typStr = "int" - case reflect.Int64: - if settingField.Type.String() == "time.Duration" { - typStr = "duration" - } else { - typStr = "int64" - } - case reflect.Bool: - typStr = "bool" - case reflect.Float32, reflect.Float64: - typStr = "float64" - } - } +func (s *Setting) AsInt() int { + if s == nil { + return 0 + } + switch v := s.Value.(type) { + case int: + return v + case float64: + return int(v) + } + return 0 +} - catMetas = append(catMetas, SettingMeta{ - Key: key, - Label: label, - Description: desc, - Type: typStr, - RequiresRestart: settingField.Tag.Get("ui_restart") == "true", - }) - } - } - // Only output categories that have editable GUI parameters - if len(catMetas) > 0 { - meta[catLabel] = catMetas - } +func (s *Setting) AsInt64() int64 { + if s == nil { + return 0 } - return meta + switch v := s.Value.(type) { + case int64: + return v + case int: + return int64(v) + case float64: + return int64(v) + } + return 0 } -// CategoryOrder returns the order of categories for UI tabs. -func CategoryOrder() []string { - var order []string - t := reflect.TypeOf(Settings{}) - for i := 0; i < t.NumField(); i++ { - field := t.Field(i) - label := field.Tag.Get("ui_label") - if label == "" { - label = field.Name - } +func (s *Setting) AsFloat64() float64 { + if s == nil { + return 0.0 + } + switch v := s.Value.(type) { + case float64: + return v + case float32: + return float64(v) + case int: + return float64(v) + } + return 0.0 +} - // Ensure category has UI elements before creating a tab! - catType := field.Type - hasUIElements := false - if catType.Kind() == reflect.Struct { - for j := 0; j < catType.NumField(); j++ { - if catType.Field(j).Tag.Get("ui_ignored") != "true" { - hasUIElements = true - break - } - } - } +func (s *Setting) AsString() string { + if s == nil { + return "" + } + if str, ok := s.Value.(string); ok { + return str + } + return "" +} - // Only tabulate categories with active inputs - if hasUIElements { - order = append(order, label) +func (s *Setting) AsDuration() time.Duration { + if s == nil { + return 0 + } + switch v := s.Value.(type) { + case time.Duration: + return v + case string: + if d, err := time.ParseDuration(v); err == nil { + return d } + case float64: + return time.Duration(v) + case int64: + return time.Duration(v) } - return order + return 0 } -const ( - KB = 1 << 10 - MB = 1 << 20 -) - -// DefaultSettings returns a new Settings instance with sensible defaults. -func DefaultSettings() *Settings { - - defaultDir := GetDownloadsDir() - - return &Settings{ - General: GeneralSettings{ - DefaultDownloadDir: defaultDir, - WarnOnDuplicate: true, - DownloadCompleteNotification: true, - AllowRemoteOpenActions: false, - AutoResume: false, - - ClipboardMonitor: true, - Theme: ThemeAdaptive, - ThemePath: "", - LogRetentionCount: 5, - LiveSpeedGraph: false, +func (s *Settings) initializeCategoriesList() { + s.CategoriesList = []*SettingsCategory{ + { + Name: "General", + Settings: []*Setting{ + s.General.DefaultDownloadDir, + s.General.WarnOnDuplicate, + s.General.DownloadCompleteNotification, + s.General.AllowRemoteOpenActions, + s.General.AutoResume, + s.General.AutoStart, + s.General.SkipUpdateCheck, + s.General.ClipboardMonitor, + s.General.Theme, + s.General.ThemePath, + s.General.LogRetentionCount, + s.General.LiveSpeedGraph, + }, }, - Network: NetworkSettings{ - MaxConnectionsPerDownload: 32, - MaxConcurrentDownloads: 3, - MaxConcurrentProbes: 3, - UserAgent: "", // Empty means use default UA - ProxyURL: "", - CustomDNS: "", - SequentialDownload: false, - MinChunkSize: 2 * MB, - WorkerBufferSize: 512 * KB, - DialHedgeCount: 4, + { + Name: "Network", + Settings: []*Setting{ + s.Network.MaxConnectionsPerDownload, + s.Network.MaxConcurrentDownloads, + s.Network.MaxConcurrentProbes, + s.Network.UserAgent, + s.Network.ProxyURL, + s.Network.CustomDNS, + s.Network.SequentialDownload, + s.Network.MinChunkSize, + s.Network.WorkerBufferSize, + s.Network.DialHedgeCount, + }, }, - Performance: PerformanceSettings{ - MaxTaskRetries: 3, - SlowWorkerThreshold: 0.3, - SlowWorkerGracePeriod: 5 * time.Second, - StallTimeout: 3 * time.Second, - SpeedEmaAlpha: 0.3, + { + Name: "Performance", + Settings: []*Setting{ + s.Performance.MaxTaskRetries, + s.Performance.SlowWorkerThreshold, + s.Performance.SlowWorkerGracePeriod, + s.Performance.StallTimeout, + s.Performance.SpeedEmaAlpha, + }, }, - Categories: CategorySettings{ - CategoryEnabled: false, - Categories: DefaultCategories(), + { + Name: "Categories", + Settings: []*Setting{ + s.Categories.CategoryEnabled, + }, }, - Extension: ExtensionSettings{ - ExtensionPrompt: true, - ChromeExtensionURL: "https://github.com/SurgeDM/Surge/releases/latest", - FirefoxExtensionURL: "https://addons.mozilla.org/en-US/firefox/addon/surge/", - AuthToken: "", // Handled specially in TUI - InstructionsURL: "https://github.com/SurgeDM/Surge#browser-extension", + { + Name: "Extension", + Settings: []*Setting{ + s.Extension.ExtensionPrompt, + s.Extension.ChromeExtensionURL, + s.Extension.FirefoxExtensionURL, + s.Extension.AuthToken, + s.Extension.InstructionsURL, + }, }, } } @@ -279,7 +258,7 @@ func LoadSettings() (*Settings, error) { settings := DefaultSettings() // Start with defaults to fill any missing fields if err := json.Unmarshal(data, settings); err != nil { - utils.Debug("Warning: corrupt settings file %s: %v \u2014 using defaults", path, err) + utils.Debug("Warning: corrupt settings file %s: %v — using defaults", path, err) defaults := DefaultSettings() defaults.StartupWarnings = append(defaults.StartupWarnings, fmt.Sprintf("Config: settings file is corrupt (%v) — all settings reset to defaults", err)) @@ -292,103 +271,608 @@ func LoadSettings() (*Settings, error) { return settings, nil } -func (s *Settings) Validate() { - s.StartupWarnings = nil - s.StartupWarnings = append(s.StartupWarnings, s.General.Validate()...) - s.StartupWarnings = append(s.StartupWarnings, s.Network.Validate()...) - s.StartupWarnings = append(s.StartupWarnings, s.Performance.Validate()...) - s.StartupWarnings = append(s.StartupWarnings, s.Categories.Validate(s.General.DefaultDownloadDir)...) - s.StartupWarnings = append(s.StartupWarnings, s.Extension.Validate()...) +// SettingMeta provides metadata for a single setting (for UI rendering). +type SettingMeta struct { + Key string // JSON key name + Label string // Human-readable label + Description string // Help text displayed in right pane + Type string // "string", "int", "int64", "bool", "duration", "float64", "auth_token", "link" + RequiresRestart bool // Whether changing this setting requires an application restart } -// Validate checks GeneralSettings for invalid paths or out-of-bounds values. -func (gs *GeneralSettings) Validate() []string { - var warnings []string - defaults := DefaultSettings().General - - if gs.Theme < 0 || gs.Theme > 2 { - gs.Theme = defaults.Theme - warnings = append(warnings, "Invalid theme reset to default") - } - if gs.LogRetentionCount < 1 || gs.LogRetentionCount > 100 { - gs.LogRetentionCount = defaults.LogRetentionCount - warnings = append(warnings, fmt.Sprintf("Log retention count reset to default (%d)", defaults.LogRetentionCount)) - } - - // Validate DefaultDownloadDir - trimmed := strings.TrimSpace(gs.DefaultDownloadDir) - if trimmed != "" { - if info, err := os.Stat(trimmed); err != nil { - // If path is invalid or inaccessible, fallback to default system downloads dir - gs.DefaultDownloadDir = defaults.DefaultDownloadDir - warnings = append(warnings, fmt.Sprintf("Download directory %q is inaccessible; reset to default", trimmed)) - } else if !info.IsDir() { - gs.DefaultDownloadDir = defaults.DefaultDownloadDir - warnings = append(warnings, fmt.Sprintf("Download directory %q is not a folder; reset to default", trimmed)) +// GetSettingsMetadata returns metadata for all settings organized by category. +func GetSettingsMetadata() map[string][]SettingMeta { + s := DefaultSettings() + meta := make(map[string][]SettingMeta) + for _, cat := range s.CategoriesList { + var list []SettingMeta + for _, set := range cat.Settings { + list = append(list, SettingMeta{ + Key: set.Key, + Label: set.Label, + Description: set.Description, + Type: set.Type, + RequiresRestart: set.NeedsRestart, + }) } + meta[cat.Name] = list } - return warnings + return meta } -// Validate checks NetworkSettings for valid IPs, URLs, and numeric bounds. -func (ns *NetworkSettings) Validate() []string { - var warnings []string - defaults := DefaultSettings().Network - aliasValue := ns.MaxConnectionsPerHost - - switch { - case ns.MaxConnectionsPerDownload <= 0 && aliasValue > 0: - ns.MaxConnectionsPerDownload = aliasValue - case ns.MaxConnectionsPerDownload > 0: - ns.MaxConnectionsPerHost = ns.MaxConnectionsPerDownload - } +// CategoryOrder returns the order of categories for UI tabs. +func CategoryOrder() []string { + return []string{"General", "Network", "Performance", "Categories", "Extension"} +} - if ns.MaxConnectionsPerDownload < 1 || ns.MaxConnectionsPerDownload > 64 { - ns.MaxConnectionsPerDownload = defaults.MaxConnectionsPerDownload - warnings = append(warnings, fmt.Sprintf("Max connections/download reset to default (%d)", defaults.MaxConnectionsPerDownload)) - } - if aliasValue != 0 && (aliasValue < 1 || aliasValue > 64) { - warnings = append(warnings, fmt.Sprintf("Max connections/download reset to default (%d)", defaults.MaxConnectionsPerDownload)) - } - ns.MaxConnectionsPerHost = ns.MaxConnectionsPerDownload - if ns.MaxConcurrentDownloads < 1 || ns.MaxConcurrentDownloads > 10 { - ns.MaxConcurrentDownloads = defaults.MaxConcurrentDownloads - warnings = append(warnings, fmt.Sprintf("Max concurrent downloads reset to default (%d)", defaults.MaxConcurrentDownloads)) - } - if ns.MaxConcurrentProbes < 1 || ns.MaxConcurrentProbes > 10 { - ns.MaxConcurrentProbes = defaults.MaxConcurrentProbes - warnings = append(warnings, fmt.Sprintf("Max concurrent probes reset to default (%d)", defaults.MaxConcurrentProbes)) - } - if ns.MinChunkSize < 100*KB { - ns.MinChunkSize = defaults.MinChunkSize - warnings = append(warnings, "Min chunk size reset to default") - } - if ns.WorkerBufferSize < 1*KB { - ns.WorkerBufferSize = defaults.WorkerBufferSize - warnings = append(warnings, "Worker buffer size reset to default") - } - if ns.DialHedgeCount < 0 || ns.DialHedgeCount > 16 { - ns.DialHedgeCount = defaults.DialHedgeCount - warnings = append(warnings, "Dial hedge count reset to default") +const ( + KB = 1 << 10 + MB = 1 << 20 +) + +const ( + ThemeAdaptive = 0 + ThemeLight = 1 + ThemeDark = 2 +) + +// DefaultSettings returns a new Settings instance with sensible defaults. +func DefaultSettings() *Settings { + defaultDir := GetDownloadsDir() + + s := &Settings{ + General: GeneralSettings{ + DefaultDownloadDir: &Setting{ + Key: "default_download_dir", + Label: "Default Download Dir", + Description: "Default directory for new downloads. Leave empty to use current directory.", + Type: "string", + DefaultValue: defaultDir, + Value: defaultDir, + ValidateFunc: func(val any) error { + sVal, ok := val.(string) + if !ok { + return fmt.Errorf("must be a string") + } + trimmed := strings.TrimSpace(sVal) + if trimmed != "" { + if info, err := os.Stat(trimmed); err != nil { + return fmt.Errorf("directory %q is inaccessible", trimmed) + } else if !info.IsDir() { + return fmt.Errorf("directory %q is not a folder", trimmed) + } + } + return nil + }, + }, + WarnOnDuplicate: &Setting{ + Key: "warn_on_duplicate", + Label: "Warn on Duplicate", + Description: "Show warning when adding a download that already exists.", + Type: "bool", + DefaultValue: true, + Value: true, + }, + DownloadCompleteNotification: &Setting{ + Key: "download_complete_notification", + Label: "Download Complete Notification", + Description: "Show system notification when a download finishes.", + Type: "bool", + DefaultValue: true, + Value: true, + }, + AllowRemoteOpenActions: &Setting{ + Key: "allow_remote_open_actions", + Label: "Allow Remote Open Actions", + Description: "Allow /open-file and /open-folder API calls from non-loopback clients. Disabled by default for security.", + Type: "bool", + NeedsRestart: true, + DefaultValue: false, + Value: false, + }, + AutoResume: &Setting{ + Key: "auto_resume", + Label: "Auto Resume", + Description: "Automatically resume paused downloads on startup.", + Type: "bool", + NeedsRestart: true, + DefaultValue: false, + Value: false, + }, + AutoStart: &Setting{ + Key: "auto_start", + Label: "Automatic Startup", + Description: "Start Surge automatically when the system boots (requires service installation).", + Type: "bool", + DefaultValue: false, + Value: false, + }, + SkipUpdateCheck: &Setting{ + Key: "skip_update_check", + Label: "Skip Update Check", + Description: "Disable automatic check for new versions on startup.", + Type: "bool", + NeedsRestart: true, + DefaultValue: false, + Value: false, + }, + ClipboardMonitor: &Setting{ + Key: "clipboard_monitor", + Label: "Clipboard Monitor", + Description: "Watch clipboard for URLs and prompt to download them.", + Type: "bool", + NeedsRestart: true, + DefaultValue: true, + Value: true, + }, + Theme: &Setting{ + Key: "theme", + Label: "App Theme", + Description: "UI Theme (System, Light, Dark).", + Type: "int", + DefaultValue: ThemeAdaptive, + Value: ThemeAdaptive, + ValidateFunc: func(val any) error { + v, ok := val.(int) + if !ok { + if f, ok := val.(float64); ok { + v = int(f) + } else { + return fmt.Errorf("invalid type") + } + } + if v < 0 || v > 2 { + return fmt.Errorf("theme must be 0, 1, or 2") + } + return nil + }, + }, + ThemePath: &Setting{ + Key: "theme_path", + Label: "Theme File", + Description: "Path to a custom .toml color scheme.", + Type: "string", + DefaultValue: "", + Value: "", + }, + LogRetentionCount: &Setting{ + Key: "log_retention_count", + Label: "Log Retention Count", + Description: "Number of recent log files to keep.", + Type: "int", + NeedsRestart: true, + DefaultValue: 5, + Value: 5, + ValidateFunc: func(val any) error { + v, ok := val.(int) + if !ok { + if f, ok := val.(float64); ok { + v = int(f) + } else { + return fmt.Errorf("invalid type") + } + } + if v < 1 || v > 100 { + return fmt.Errorf("must be between 1 and 100") + } + return nil + }, + }, + LiveSpeedGraph: &Setting{ + Key: "live_speed_graph", + Label: "Live Speed Graph", + Description: "Use live speed for graph instead of EMA smoothed speed.", + Type: "bool", + DefaultValue: false, + Value: false, + }, + }, + Network: NetworkSettings{ + MaxConnectionsPerDownload: &Setting{ + Key: "max_connections_per_host", + Label: "Max Connections/Download", + Description: "Maximum concurrent connections per download (1-64).", + Type: "int", + DefaultValue: 32, + Value: 32, + ValidateFunc: func(val any) error { + v, ok := val.(int) + if !ok { + if f, ok := val.(float64); ok { + v = int(f) + } else { + return fmt.Errorf("invalid type") + } + } + if v < 1 || v > 64 { + return fmt.Errorf("must be between 1 and 64") + } + return nil + }, + }, + MaxConcurrentDownloads: &Setting{ + Key: "max_concurrent_downloads", + Label: "Max Concurrent Downloads", + Description: "Maximum number of downloads running at once (1-10).", + Type: "int", + NeedsRestart: true, + DefaultValue: 3, + Value: 3, + ValidateFunc: func(val any) error { + v, ok := val.(int) + if !ok { + if f, ok := val.(float64); ok { + v = int(f) + } else { + return fmt.Errorf("invalid type") + } + } + if v < 1 || v > 10 { + return fmt.Errorf("must be between 1 and 10") + } + return nil + }, + }, + MaxConcurrentProbes: &Setting{ + Key: "max_concurrent_probes", + Label: "Max Concurrent Probes", + Description: "Maximum number of simultaneous server probes when adding many downloads at once (1-10).", + Type: "int", + NeedsRestart: true, + DefaultValue: 3, + Value: 3, + ValidateFunc: func(val any) error { + v, ok := val.(int) + if !ok { + if f, ok := val.(float64); ok { + v = int(f) + } else { + return fmt.Errorf("invalid type") + } + } + if v < 1 || v > 10 { + return fmt.Errorf("must be between 1 and 10") + } + return nil + }, + }, + UserAgent: &Setting{ + Key: "user_agent", + Label: "User Agent", + Description: "Custom User-Agent string for HTTP requests. Leave empty for default.", + Type: "string", + DefaultValue: "", + Value: "", + }, + ProxyURL: &Setting{ + Key: "proxy_url", + Label: "Proxy URL", + Description: "HTTP/HTTPS proxy URL (e.g. http://127.0.0.1:1700). Leave empty to use system default.", + Type: "string", + DefaultValue: "", + Value: "", + ValidateFunc: func(val any) error { + sVal, ok := val.(string) + if !ok { + return fmt.Errorf("must be a string") + } + if sVal != "" { + u, err := url.Parse(sVal) + if err != nil || u.Scheme == "" || u.Host == "" { + return fmt.Errorf("invalid proxy URL") + } + } + return nil + }, + }, + CustomDNS: &Setting{ + Key: "custom_dns", + Label: "Custom DNS Server", + Description: "Set custom DNS (e.g., 1.1.1.1:53, 94.140.14.14:53). Leave empty for system.", + Type: "string", + DefaultValue: "", + Value: "", + ValidateFunc: func(val any) error { + sVal, ok := val.(string) + if !ok { + return fmt.Errorf("must be a string") + } + return ValidateDNSList(sVal) + }, + }, + SequentialDownload: &Setting{ + Key: "sequential_download", + Label: "Sequential Download", + Description: "Download pieces in order (Streaming Mode). May be slower.", + Type: "bool", + DefaultValue: false, + Value: false, + }, + MinChunkSize: &Setting{ + Key: "min_chunk_size", + Label: "Min Chunk Size", + Description: "Minimum download chunk size in MB (e.g., 2).", + Type: "int64", + DefaultValue: int64(2 * MB), + Value: int64(2 * MB), + ValidateFunc: func(val any) error { + var v int64 + switch actual := val.(type) { + case int64: + v = actual + case int: + v = int64(actual) + case float64: + v = int64(actual) + default: + return fmt.Errorf("invalid type") + } + if v < 100*KB { + return fmt.Errorf("min chunk size must be at least 100KB") + } + return nil + }, + }, + WorkerBufferSize: &Setting{ + Key: "worker_buffer_size", + Label: "Worker Buffer Size", + Description: "I/O buffer size per worker in KB (e.g., 512).", + Type: "int", + DefaultValue: int(512 * KB), + Value: int(512 * KB), + ValidateFunc: func(val any) error { + v, ok := val.(int) + if !ok { + if f, ok := val.(float64); ok { + v = int(f) + } else { + return fmt.Errorf("invalid type") + } + } + if v < 1*KB { + return fmt.Errorf("worker buffer size must be at least 1KB") + } + return nil + }, + }, + DialHedgeCount: &Setting{ + Key: "dial_hedge_count", + Label: "Dial Hedge Count", + Description: "Number of extra connections to dial pre-emptively to avoid slow connects (0-16).", + Type: "int", + DefaultValue: 4, + Value: 4, + ValidateFunc: func(val any) error { + v, ok := val.(int) + if !ok { + if f, ok := val.(float64); ok { + v = int(f) + } else { + return fmt.Errorf("invalid type") + } + } + if v < 0 || v > 16 { + return fmt.Errorf("must be between 0 and 16") + } + return nil + }, + }, + }, + Performance: PerformanceSettings{ + MaxTaskRetries: &Setting{ + Key: "max_task_retries", + Label: "Max Task Retries", + Description: "Number of times to retry a failed chunk before giving up.", + Type: "int", + DefaultValue: 3, + Value: 3, + ValidateFunc: func(val any) error { + v, ok := val.(int) + if !ok { + if f, ok := val.(float64); ok { + v = int(f) + } else { + return fmt.Errorf("invalid type") + } + } + if v < 0 || v > 10 { + return fmt.Errorf("must be between 0 and 10") + } + return nil + }, + }, + SlowWorkerThreshold: &Setting{ + Key: "slow_worker_threshold", + Label: "Slow Worker Threshold", + Description: "Restart workers slower than this fraction of mean speed (0.0-1.0, 0 disables relative slow-worker checks).", + Type: "float64", + DefaultValue: 0.3, + Value: 0.3, + ValidateFunc: func(val any) error { + var v float64 + switch actual := val.(type) { + case float64: + v = actual + case int: + v = float64(actual) + default: + return fmt.Errorf("invalid type") + } + if v < 0.0 || v > 1.0 { + return fmt.Errorf("must be between 0.0 and 1.0") + } + return nil + }, + }, + SlowWorkerGracePeriod: &Setting{ + Key: "slow_worker_grace_period", + Label: "Slow Worker Grace", + Description: "Grace period before checking worker speed (e.g., 5s, 0 checks immediately).", + Type: "duration", + DefaultValue: 5 * time.Second, + Value: 5 * time.Second, + ValidateFunc: func(val any) error { + var v int64 + switch actual := val.(type) { + case time.Duration: + v = int64(actual) + case float64: + v = int64(actual) + case int64: + v = actual + default: + return fmt.Errorf("invalid type") + } + if v < 0 { + return fmt.Errorf("must be non-negative") + } + return nil + }, + }, + StallTimeout: &Setting{ + Key: "stall_timeout", + Label: "Stall Timeout", + Description: "Restart workers with no data for this duration (e.g., 5s, 0 disables stall detection).", + Type: "duration", + DefaultValue: 3 * time.Second, + Value: 3 * time.Second, + ValidateFunc: func(val any) error { + var v int64 + switch actual := val.(type) { + case time.Duration: + v = int64(actual) + case float64: + v = int64(actual) + case int64: + v = actual + default: + return fmt.Errorf("invalid type") + } + if v < 0 { + return fmt.Errorf("must be non-negative") + } + return nil + }, + }, + SpeedEmaAlpha: &Setting{ + Key: "speed_ema_alpha", + Label: "Speed EMA Alpha", + Description: "Exponential moving average smoothing factor (0.0-1.0, 0 disables smoothing).", + Type: "float64", + DefaultValue: 0.3, + Value: 0.3, + ValidateFunc: func(val any) error { + var v float64 + switch actual := val.(type) { + case float64: + v = actual + case int: + v = float64(actual) + default: + return fmt.Errorf("invalid type") + } + if v < 0.0 || v > 1.0 { + return fmt.Errorf("must be between 0.0 and 1.0") + } + return nil + }, + }, + }, + Categories: CategorySettings{ + CategoryEnabled: &Setting{ + Key: "category_enabled", + Label: "Manage Categories", + Description: "Sort downloads into subfolders by file type. Press Enter to open Category Manager.", + Type: "bool", + DefaultValue: false, + Value: false, + }, + Categories: DefaultCategories(), + }, + Extension: ExtensionSettings{ + ExtensionPrompt: &Setting{ + Key: "extension_prompt", + Label: "Extension Prompt", + Description: "Prompt for confirmation when adding downloads via browser extension.", + Type: "bool", + DefaultValue: true, + Value: true, + }, + ChromeExtensionURL: &Setting{ + Key: "chrome_extension_url", + Label: "Get Chrome Extension", + Description: "Open the Surge Chrome extension page.", + Type: "link", + DefaultValue: "https://github.com/SurgeDM/Surge/releases/latest", + Value: "https://github.com/SurgeDM/Surge/releases/latest", + }, + FirefoxExtensionURL: &Setting{ + Key: "firefox_extension_url", + Label: "Get Firefox Extension", + Description: "Open the Surge Firefox extension page.", + Type: "link", + DefaultValue: "https://addons.mozilla.org/en-US/firefox/addon/surge/", + Value: "https://addons.mozilla.org/en-US/firefox/addon/surge/", + }, + AuthToken: &Setting{ + Key: "auth_token", + Label: "Auth Token", + Description: "Your authentication token. Use this to connect the Browser Extension to Surge.", + Type: "auth_token", + DefaultValue: "", + Value: "", + }, + InstructionsURL: &Setting{ + Key: "instructions_url", + Label: "Setup Instructions", + Description: "View detailed instructions on how to set up the Surge browser extension.", + Type: "link", + DefaultValue: "https://github.com/SurgeDM/Surge#browser-extension", + Value: "https://github.com/SurgeDM/Surge#browser-extension", + }, + }, } - // Validate ProxyURL if set - if ns.ProxyURL != "" { - u, err := url.Parse(ns.ProxyURL) - if err != nil || u.Scheme == "" || u.Host == "" { - ns.ProxyURL = defaults.ProxyURL - warnings = append(warnings, "Invalid proxy URL reset to default") + s.initializeCategoriesList() + return s +} + +func (s *Settings) Validate() []string { + s.StartupWarnings = nil + + // Loop over all settings in all categories + for _, cat := range s.CategoriesList { + for _, set := range cat.Settings { + // If validation fails, log a warning and rollback to DefaultValue + if err := set.Validate(set.Value); err != nil { + set.Value = set.DefaultValue + s.StartupWarnings = append(s.StartupWarnings, fmt.Sprintf("Reset setting '%s' to default: %v", set.Key, err)) + } } } - // Validate CustomDNS if set - if ns.CustomDNS != "" { - if err := ValidateDNSList(ns.CustomDNS); err != nil { - ns.CustomDNS = defaults.CustomDNS - warnings = append(warnings, "Invalid DNS configuration reset to default") + // Dynamic extra validations for categories list in CategorySettings + validCats := make([]Category, 0, len(s.Categories.Categories)) + for _, cat := range s.Categories.Categories { + if err := cat.Validate(); err == nil { + // Extra path check for each category + catPath := strings.TrimSpace(cat.Path) + if catPath != "" { + if info, err := os.Stat(catPath); err != nil || !info.IsDir() { + // Fallback to default download dir + cat.Path = s.General.DefaultDownloadDir.AsString() + s.StartupWarnings = append(s.StartupWarnings, fmt.Sprintf("Category %q path is broken; reset to default", cat.Name)) + } + } + validCats = append(validCats, cat) + } else { + s.StartupWarnings = append(s.StartupWarnings, fmt.Sprintf("Removed invalid category %q: %v", cat.Name, err)) + utils.Debug("Config: Removing invalid category %q: %v", cat.Name, err) } } - return warnings + s.Categories.Categories = validCats + + return s.StartupWarnings } // ValidateDNSList checks if a comma-separated list of DNS servers (IP or IP:port) is valid. @@ -417,69 +901,6 @@ func ValidateDNSList(s string) error { return nil } -// Validate checks PerformanceSettings for valid floating point ranges and durations. -func (ps *PerformanceSettings) Validate() []string { - var warnings []string - defaults := DefaultSettings().Performance - - if ps.MaxTaskRetries < 0 || ps.MaxTaskRetries > 10 { - ps.MaxTaskRetries = defaults.MaxTaskRetries - warnings = append(warnings, fmt.Sprintf("Max task retries reset to default (%d)", defaults.MaxTaskRetries)) - } - if ps.SlowWorkerThreshold < 0.0 || ps.SlowWorkerThreshold > 1.0 { - ps.SlowWorkerThreshold = defaults.SlowWorkerThreshold - warnings = append(warnings, "Slow worker threshold reset to default") - } - if ps.SpeedEmaAlpha < 0.0 || ps.SpeedEmaAlpha > 1.0 { - ps.SpeedEmaAlpha = defaults.SpeedEmaAlpha - warnings = append(warnings, "Speed smoothing factor reset to default") - } - if ps.SlowWorkerGracePeriod < 0 { - ps.SlowWorkerGracePeriod = defaults.SlowWorkerGracePeriod - warnings = append(warnings, "Slow worker grace period reset to default") - } - if ps.StallTimeout < 0 { - ps.StallTimeout = defaults.StallTimeout - warnings = append(warnings, "Stall timeout reset to default") - } - return warnings -} - -// Validate checks CategorySettings and ensures all defined categories are valid. -func (cs *CategorySettings) Validate(fallbackDir string) []string { - var warnings []string - validCats := make([]Category, 0, len(cs.Categories)) - for _, cat := range cs.Categories { - if err := cat.Validate(); err == nil { - // Extra path check for each category - catPath := strings.TrimSpace(cat.Path) - if catPath != "" { - if info, err := os.Stat(catPath); err != nil || !info.IsDir() { - // Fallback to validated default download dir for this category if path is broken - cat.Path = fallbackDir - warnings = append(warnings, fmt.Sprintf("Category %q path is broken; reset to default", cat.Name)) - } - } - validCats = append(validCats, cat) - } else { - warnings = append(warnings, fmt.Sprintf("Removed invalid category %q: %v", cat.Name, err)) - utils.Debug("Config: Removing invalid category %q: %v", cat.Name, err) - } - } - - cs.Categories = validCats - return warnings -} - -// Validate checks ExtensionSettings for any necessary field sanitization. -func (es *ExtensionSettings) Validate() []string { - var warnings []string - // Extension settings are currently mostly URLs or booleans that don't - // require strict range enforcement, but we maintain the Validate method - // for future consistency and testing. - return warnings -} - // SaveSettings saves settings to disk atomically. func SaveSettings(s *Settings) error { path := GetSettingsPath() @@ -506,18 +927,32 @@ func SaveSettings(s *Settings) error { // ToRuntimeConfig creates the engine runtime config from validated settings. func (s *Settings) ToRuntimeConfig() *types.RuntimeConfig { return &types.RuntimeConfig{ - MaxConnectionsPerDownload: s.Network.MaxConnectionsPerDownload, - UserAgent: s.Network.UserAgent, - ProxyURL: s.Network.ProxyURL, - CustomDNS: s.Network.CustomDNS, - SequentialDownload: s.Network.SequentialDownload, - MinChunkSize: s.Network.MinChunkSize, - WorkerBufferSize: s.Network.WorkerBufferSize, - DialHedgeCount: s.Network.DialHedgeCount, - MaxTaskRetries: s.Performance.MaxTaskRetries, - SlowWorkerThreshold: s.Performance.SlowWorkerThreshold, - SlowWorkerGracePeriod: s.Performance.SlowWorkerGracePeriod, - StallTimeout: s.Performance.StallTimeout, - SpeedEmaAlpha: s.Performance.SpeedEmaAlpha, + MaxConnectionsPerDownload: s.Network.MaxConnectionsPerDownload.AsInt(), + UserAgent: s.Network.UserAgent.AsString(), + ProxyURL: s.Network.ProxyURL.AsString(), + CustomDNS: s.Network.CustomDNS.AsString(), + SequentialDownload: s.Network.SequentialDownload.AsBool(), + MinChunkSize: s.Network.MinChunkSize.AsInt64(), + WorkerBufferSize: s.Network.WorkerBufferSize.AsInt(), + DialHedgeCount: s.Network.DialHedgeCount.AsInt(), + MaxTaskRetries: s.Performance.MaxTaskRetries.AsInt(), + SlowWorkerThreshold: s.Performance.SlowWorkerThreshold.AsFloat64(), + SlowWorkerGracePeriod: s.Performance.SlowWorkerGracePeriod.AsDuration(), + StallTimeout: s.Performance.StallTimeout.AsDuration(), + SpeedEmaAlpha: s.Performance.SpeedEmaAlpha.AsFloat64(), + } +} + +// Clone returns a deep copy of the settings. +func (s *Settings) Clone() *Settings { + if s == nil { + return nil + } + data, err := json.Marshal(s) + if err != nil { + return nil } + cloned := DefaultSettings() + _ = json.Unmarshal(data, cloned) + return cloned } diff --git a/internal/config/settings_schema.go b/internal/config/settings_schema.go new file mode 100644 index 00000000..7c7248b2 --- /dev/null +++ b/internal/config/settings_schema.go @@ -0,0 +1,34 @@ +package config + +// Setting represents a single application configuration option. +// It encapsulates the live value, its default fallback, UI/CLI metadata, +// reboot triggers, and localized validation logic into a unified, self-contained unit. +// This architecture decouples setting definitions from static struct fields, +// allowing dynamic schema resolution, introspection, and centralized validation. +type Setting struct { + Key string `json:"key"` + Label string `json:"label"` + Description string `json:"description"` + NeedsRestart bool `json:"needs_restart"` + Type string `json:"type"` // "string", "int", "bool", "float64", "duration", "int64", "auth_token", "link" + + Value any `json:"value"` + DefaultValue any `json:"default_value"` + + // ValidateFunc is a custom validator for this setting. + ValidateFunc func(val any) error `json:"-"` +} + +// Validate checks the given value against any custom validation rule. +func (s *Setting) Validate(val any) error { + if s.ValidateFunc != nil { + return s.ValidateFunc(val) + } + return nil +} + +// SettingsCategory represents a group of related Setting options. +type SettingsCategory struct { + Name string `json:"name"` + Settings []*Setting `json:"settings"` +} diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go index 16b52526..4320b3bc 100644 --- a/internal/config/settings_test.go +++ b/internal/config/settings_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "os" "path/filepath" - "reflect" "strings" "testing" "time" @@ -19,91 +18,88 @@ func TestDefaultSettings(t *testing.T) { // Verify General settings t.Run("GeneralSettings", func(t *testing.T) { - // DefaultDownloadDir can be empty (for current directory) or a valid path - if settings.General.DefaultDownloadDir != "" { - if info, err := os.Stat(settings.General.DefaultDownloadDir); err != nil || !info.IsDir() { - t.Errorf("DefaultDownloadDir set to invalid path: %s", settings.General.DefaultDownloadDir) + if settings.General.DefaultDownloadDir.AsString() != "" { + if info, err := os.Stat(settings.General.DefaultDownloadDir.AsString()); err != nil || !info.IsDir() { + t.Errorf("DefaultDownloadDir set to invalid path: %s", settings.General.DefaultDownloadDir.AsString()) } } - if !settings.General.WarnOnDuplicate { + if !settings.General.WarnOnDuplicate.AsBool() { t.Error("WarnOnDuplicate should be true by default") } - if settings.General.AllowRemoteOpenActions { + if settings.General.AllowRemoteOpenActions.AsBool() { t.Error("AllowRemoteOpenActions should be false by default") } - if settings.General.AutoResume { + if settings.General.AutoResume.AsBool() { t.Error("AutoResume should be false by default") } }) // Verify Connection settings t.Run("NetworkSettings", func(t *testing.T) { - if settings.Network.MaxConnectionsPerDownload <= 0 { - t.Errorf("MaxConnectionsPerDownload should be positive, got: %d", settings.Network.MaxConnectionsPerDownload) + if settings.Network.MaxConnectionsPerDownload.AsInt() <= 0 { + t.Errorf("MaxConnectionsPerDownload should be positive, got: %d", settings.Network.MaxConnectionsPerDownload.AsInt()) } - if settings.Network.MaxConnectionsPerDownload > 64 { - t.Errorf("MaxConnectionsPerDownload shouldn't exceed 64, got: %d", settings.Network.MaxConnectionsPerDownload) + if settings.Network.MaxConnectionsPerDownload.AsInt() > 64 { + t.Errorf("MaxConnectionsPerDownload shouldn't exceed 64, got: %d", settings.Network.MaxConnectionsPerDownload.AsInt()) } - // UserAgent can be empty (means use default) - if settings.Network.SequentialDownload { + if settings.Network.SequentialDownload.AsBool() { t.Error("SequentialDownload should be false by default") } - if settings.Network.DialHedgeCount != 4 { - t.Errorf("DialHedgeCount should be 4 by default, got: %d", settings.Network.DialHedgeCount) + if settings.Network.DialHedgeCount.AsInt() != 4 { + t.Errorf("DialHedgeCount should be 4 by default, got: %d", settings.Network.DialHedgeCount.AsInt()) } }) // Verify Chunk settings t.Run("NetworkChunkSettings", func(t *testing.T) { - if settings.Network.MinChunkSize <= 0 { - t.Errorf("MinChunkSize should be positive, got: %d", settings.Network.MinChunkSize) + if settings.Network.MinChunkSize.AsInt64() <= 0 { + t.Errorf("MinChunkSize should be positive, got: %d", settings.Network.MinChunkSize.AsInt64()) } - if settings.Network.WorkerBufferSize <= 0 { - t.Errorf("WorkerBufferSize should be positive, got: %d", settings.Network.WorkerBufferSize) + if settings.Network.WorkerBufferSize.AsInt() <= 0 { + t.Errorf("WorkerBufferSize should be positive, got: %d", settings.Network.WorkerBufferSize.AsInt()) } }) // Verify Performance settings t.Run("PerformanceSettings", func(t *testing.T) { - if settings.Performance.MaxTaskRetries < 0 { - t.Errorf("MaxTaskRetries should be non-negative, got: %d", settings.Performance.MaxTaskRetries) + if settings.Performance.MaxTaskRetries.AsInt() < 0 { + t.Errorf("MaxTaskRetries should be non-negative, got: %d", settings.Performance.MaxTaskRetries.AsInt()) } - if settings.Performance.SlowWorkerThreshold < 0 || settings.Performance.SlowWorkerThreshold > 1 { - t.Errorf("SlowWorkerThreshold should be between 0 and 1, got: %f", settings.Performance.SlowWorkerThreshold) + if settings.Performance.SlowWorkerThreshold.AsFloat64() < 0 || settings.Performance.SlowWorkerThreshold.AsFloat64() > 1 { + t.Errorf("SlowWorkerThreshold should be between 0 and 1, got: %f", settings.Performance.SlowWorkerThreshold.AsFloat64()) } - if settings.Performance.SlowWorkerGracePeriod <= 0 { - t.Errorf("SlowWorkerGracePeriod should be positive, got: %v", settings.Performance.SlowWorkerGracePeriod) + if settings.Performance.SlowWorkerGracePeriod.AsDuration() <= 0 { + t.Errorf("SlowWorkerGracePeriod should be positive, got: %v", settings.Performance.SlowWorkerGracePeriod.AsDuration()) } - if settings.Performance.StallTimeout <= 0 { - t.Errorf("StallTimeout should be positive, got: %v", settings.Performance.StallTimeout) + if settings.Performance.StallTimeout.AsDuration() <= 0 { + t.Errorf("StallTimeout should be positive, got: %v", settings.Performance.StallTimeout.AsDuration()) } - if settings.Performance.SpeedEmaAlpha < 0 || settings.Performance.SpeedEmaAlpha > 1 { - t.Errorf("SpeedEmaAlpha should be between 0 and 1, got: %f", settings.Performance.SpeedEmaAlpha) + if settings.Performance.SpeedEmaAlpha.AsFloat64() < 0 || settings.Performance.SpeedEmaAlpha.AsFloat64() > 1 { + t.Errorf("SpeedEmaAlpha should be between 0 and 1, got: %f", settings.Performance.SpeedEmaAlpha.AsFloat64()) } }) // Verify Extension settings t.Run("ExtensionSettings", func(t *testing.T) { - if !settings.Extension.ExtensionPrompt { - t.Error("ExtensionPrompt should be true by default in its new home") + if !settings.Extension.ExtensionPrompt.AsBool() { + t.Error("ExtensionPrompt should be true by default") } - if settings.Extension.ChromeExtensionURL == "" { + if settings.Extension.ChromeExtensionURL.AsString() == "" { t.Error("ChromeExtensionURL should not be empty") } - if settings.Extension.FirefoxExtensionURL == "" { + if settings.Extension.FirefoxExtensionURL.AsString() == "" { t.Error("FirefoxExtensionURL should not be empty") } - if settings.Extension.InstructionsURL == "" { + if settings.Extension.InstructionsURL.AsString() == "" { t.Error("InstructionsURL should not be empty") } }) } func TestDefaultSettings_Consistency(t *testing.T) { - // Multiple calls should return equivalent (but not same pointer) settings s1 := DefaultSettings() s2 := DefaultSettings() @@ -111,8 +107,7 @@ func TestDefaultSettings_Consistency(t *testing.T) { t.Error("DefaultSettings should return new instance each time") } - // Values should be equal - if s1.Network.MaxConnectionsPerDownload != s2.Network.MaxConnectionsPerDownload { + if s1.Network.MaxConnectionsPerDownload.AsInt() != s2.Network.MaxConnectionsPerDownload.AsInt() { t.Error("Default settings should be consistent") } } @@ -124,71 +119,49 @@ func TestGetSettingsPath(t *testing.T) { t.Error("GetSettingsPath returned empty string") } - // Should be under surge directory surgeDir := GetSurgeDir() if !strings.HasPrefix(path, surgeDir) { t.Errorf("Settings path should be under surge dir. Path: %s, SurgeDir: %s", path, surgeDir) } - // Should end with settings.json if !strings.HasSuffix(path, "settings.json") { t.Errorf("Settings path should end with 'settings.json', got: %s", path) } - - // Should be absolute path - if !filepath.IsAbs(path) { - t.Errorf("Settings path should be absolute, got: %s", path) - } } func TestSaveAndLoadSettings(t *testing.T) { - // Create temp directory for testing tmpDir, err := os.MkdirTemp("", "surge-settings-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer func() { _ = os.RemoveAll(tmpDir) }() - // We'll test the JSON serialization directly since we can't easily mock GetSettingsPath - original := &Settings{ - General: GeneralSettings{ - DefaultDownloadDir: tmpDir, - WarnOnDuplicate: false, - AutoResume: true, - }, - Extension: ExtensionSettings{ - ExtensionPrompt: true, - }, - Network: NetworkSettings{ - MaxConnectionsPerDownload: 16, - MaxConcurrentDownloads: 7, - UserAgent: "TestAgent/1.0", - MinChunkSize: 1 * MB, - WorkerBufferSize: 256 * KB, - DialHedgeCount: 6, - }, - Performance: PerformanceSettings{ - MaxTaskRetries: 5, - SlowWorkerThreshold: 0.5, - SlowWorkerGracePeriod: 10 * time.Second, - StallTimeout: 5 * time.Second, - SpeedEmaAlpha: 0.5, - }, - } + original := DefaultSettings() + original.General.DefaultDownloadDir.Value = tmpDir + original.General.WarnOnDuplicate.Value = false + original.General.AutoResume.Value = true + original.Network.MaxConnectionsPerDownload.Value = 16 + original.Network.MaxConcurrentDownloads.Value = 7 + original.Network.UserAgent.Value = "TestAgent/1.0" + original.Network.MinChunkSize.Value = int64(1 * MB) + original.Network.WorkerBufferSize.Value = 256 * KB + original.Network.DialHedgeCount.Value = 6 + original.Performance.MaxTaskRetries.Value = 5 + original.Performance.SlowWorkerThreshold.Value = 0.5 + original.Performance.SlowWorkerGracePeriod.Value = 10 * time.Second + original.Performance.StallTimeout.Value = 5 * time.Second + original.Performance.SpeedEmaAlpha.Value = 0.5 - // Serialize to JSON data, err := json.MarshalIndent(original, "", " ") if err != nil { t.Fatalf("Failed to marshal settings: %v", err) } - // Write to temp file testPath := filepath.Join(tmpDir, "test_settings.json") if err := os.WriteFile(testPath, data, 0o644); err != nil { t.Fatalf("Failed to write settings file: %v", err) } - // Read back readData, err := os.ReadFile(testPath) if err != nil { t.Fatalf("Failed to read settings file: %v", err) @@ -199,48 +172,25 @@ func TestSaveAndLoadSettings(t *testing.T) { t.Fatalf("Failed to unmarshal settings: %v", err) } - // Verify all fields - if loaded.General.DefaultDownloadDir != original.General.DefaultDownloadDir { - t.Errorf("DefaultDownloadDir mismatch: got %q, want %q", - loaded.General.DefaultDownloadDir, original.General.DefaultDownloadDir) + if loaded.General.DefaultDownloadDir.AsString() != original.General.DefaultDownloadDir.AsString() { + t.Errorf("DefaultDownloadDir mismatch: got %q, want %q", loaded.General.DefaultDownloadDir.AsString(), original.General.DefaultDownloadDir.AsString()) } - if loaded.General.WarnOnDuplicate != original.General.WarnOnDuplicate { + if loaded.General.WarnOnDuplicate.AsBool() != original.General.WarnOnDuplicate.AsBool() { t.Error("WarnOnDuplicate mismatch") } - if loaded.Extension.ExtensionPrompt != original.Extension.ExtensionPrompt { - t.Error("ExtensionPrompt mismatch") - } - if loaded.Network.MaxConcurrentDownloads != original.Network.MaxConcurrentDownloads { - t.Errorf("MaxConcurrentDownloads mismatch: got %d, want %d", loaded.Network.MaxConcurrentDownloads, original.Network.MaxConcurrentDownloads) - } - if loaded.Network.MaxConnectionsPerDownload != original.Network.MaxConnectionsPerDownload { - t.Error("MaxConnectionsPerDownload mismatch") - } - if loaded.Network.UserAgent != original.Network.UserAgent { - t.Error("UserAgent mismatch") - } - if loaded.Network.DialHedgeCount != original.Network.DialHedgeCount { - t.Errorf("DialHedgeCount mismatch: got %d, want %d", loaded.Network.DialHedgeCount, original.Network.DialHedgeCount) - } - if loaded.Network.MinChunkSize != original.Network.MinChunkSize { - t.Error("MinChunkSize mismatch") - } - if loaded.Performance.SlowWorkerGracePeriod != original.Performance.SlowWorkerGracePeriod { - t.Error("SlowWorkerGracePeriod mismatch") + if loaded.Network.MaxConcurrentDownloads.AsInt() != original.Network.MaxConcurrentDownloads.AsInt() { + t.Error("MaxConcurrentDownloads mismatch") } } func TestLoadSettings_MissingFile(t *testing.T) { - // LoadSettings should return defaults when file doesn't exist settings, err := LoadSettings() if err != nil { - // Might fail if config dir doesn't exist, which is okay t.Logf("LoadSettings returned error (may be expected): %v", err) } if settings != nil { - // If we got settings, they should have sensible defaults - if settings.Network.MaxConnectionsPerDownload <= 0 { + if settings.Network.MaxConnectionsPerDownload.AsInt() <= 0 { t.Error("Should return default settings with valid values") } } @@ -253,13 +203,11 @@ func TestLoadSettings_CorruptedJSON(t *testing.T) { } defer func() { _ = os.RemoveAll(tmpDir) }() - // Write corrupted JSON testPath := filepath.Join(tmpDir, "corrupt.json") if err := os.WriteFile(testPath, []byte("{invalid json"), 0o644); err != nil { t.Fatalf("Failed to write file: %v", err) } - // Read and attempt to unmarshal data, _ := os.ReadFile(testPath) settings := DefaultSettings() err = json.Unmarshal(data, settings) @@ -269,206 +217,17 @@ func TestLoadSettings_CorruptedJSON(t *testing.T) { } } -func TestLoadSettings_CorruptedJSON_FallsBackToDefaults(t *testing.T) { - // Cenário - tmpDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmpDir) - - surgeDir := filepath.Join(tmpDir, "surge") - if err := os.MkdirAll(surgeDir, 0o755); err != nil { - t.Fatalf("Failed to create surge dir: %v", err) - } - corruptPath := filepath.Join(surgeDir, "settings.json") - if err := os.WriteFile(corruptPath, []byte("{not valid json!!!"), 0o644); err != nil { - t.Fatalf("Failed to write corrupt settings: %v", err) - } - - // Ação - settings, err := LoadSettings() - - // Validação - if err != nil { - t.Fatalf("LoadSettings should not return error for corrupt JSON, got: %v", err) - } - if settings == nil { - t.Fatal("LoadSettings should return defaults, got nil") - return - } - - defaults := DefaultSettings() - if settings.Network.MaxConnectionsPerDownload != defaults.Network.MaxConnectionsPerDownload { - t.Errorf("Expected default MaxConnectionsPerDownload %d, got %d", - defaults.Network.MaxConnectionsPerDownload, settings.Network.MaxConnectionsPerDownload) - } - if settings.Performance.MaxTaskRetries != defaults.Performance.MaxTaskRetries { - t.Errorf("Expected default MaxTaskRetries %d, got %d", - defaults.Performance.MaxTaskRetries, settings.Performance.MaxTaskRetries) - } -} - -func TestLoadSettings_TruncatedJSON_FallsBackToDefaults(t *testing.T) { - // Cenário - tmpDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmpDir) - - surgeDir := filepath.Join(tmpDir, "surge") - if err := os.MkdirAll(surgeDir, 0o755); err != nil { - t.Fatalf("Failed to create surge dir: %v", err) - } - // Simula crash durante SaveSettings — arquivo truncado - truncated := `{"general": {"default_download_dir": "/home/user/Downloads", "warn_on_duplicate": tr` - corruptPath := filepath.Join(surgeDir, "settings.json") - if err := os.WriteFile(corruptPath, []byte(truncated), 0o644); err != nil { - t.Fatalf("Failed to write truncated settings: %v", err) - } - - // Ação - settings, err := LoadSettings() - - // Validação - if err != nil { - t.Fatalf("LoadSettings should not return error for truncated JSON, got: %v", err) - } - if settings == nil { - t.Fatal("LoadSettings should return defaults, got nil") - return - } - if settings.Network.MaxConnectionsPerDownload != DefaultSettings().Network.MaxConnectionsPerDownload { - t.Error("Expected default settings after truncated JSON") - } -} - -func TestLoadSettings_EmptyFile_FallsBackToDefaults(t *testing.T) { - // Cenário - tmpDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmpDir) - - surgeDir := filepath.Join(tmpDir, "surge") - if err := os.MkdirAll(surgeDir, 0o755); err != nil { - t.Fatalf("Failed to create surge dir: %v", err) - } - emptyPath := filepath.Join(surgeDir, "settings.json") - if err := os.WriteFile(emptyPath, []byte(""), 0o644); err != nil { - t.Fatalf("Failed to write empty settings: %v", err) - } - - // Ação - settings, err := LoadSettings() - - // Validação - if err != nil { - t.Fatalf("LoadSettings should not return error for empty file, got: %v", err) - } - if settings == nil { - t.Fatal("LoadSettings should return defaults, got nil") - } -} - -func TestLoadSettings_PartialJSON(t *testing.T) { - // Test that missing fields get filled with defaults - partial := `{ - "general": { - "default_download_dir": "/custom/path" - } - }` - - settings := DefaultSettings() - if err := json.Unmarshal([]byte(partial), settings); err != nil { - t.Fatalf("Failed to unmarshal partial JSON: %v", err) - } - - // Custom field should be set - if settings.General.DefaultDownloadDir != "/custom/path" { - t.Errorf("Custom field not set: %s", settings.General.DefaultDownloadDir) - } - - // Default field should remain (from the defaults we started with) - if settings.Network.MaxConnectionsPerDownload <= 0 { - t.Error("Default values should be preserved for missing fields") - } -} - func TestToRuntimeConfig(t *testing.T) { settings := DefaultSettings() runtime := settings.ToRuntimeConfig() if runtime == nil { t.Fatal("ToRuntimeConfig returned nil") - return } - // Verify all fields are correctly mapped - if runtime.MaxConnectionsPerDownload != settings.Network.MaxConnectionsPerDownload { + if runtime.MaxConnectionsPerDownload != settings.Network.MaxConnectionsPerDownload.AsInt() { t.Error("MaxConnectionsPerDownload not correctly mapped") } - - if runtime.UserAgent != settings.Network.UserAgent { - t.Error("UserAgent not correctly mapped") - } - if runtime.MinChunkSize != settings.Network.MinChunkSize { - t.Error("MinChunkSize not correctly mapped") - } - if runtime.WorkerBufferSize != settings.Network.WorkerBufferSize { - t.Error("WorkerBufferSize not correctly mapped") - } - if runtime.DialHedgeCount != settings.Network.DialHedgeCount { - t.Error("DialHedgeCount not correctly mapped") - } - if runtime.MaxTaskRetries != settings.Performance.MaxTaskRetries { - t.Error("MaxTaskRetries not correctly mapped") - } - if runtime.SlowWorkerThreshold != settings.Performance.SlowWorkerThreshold { - t.Error("SlowWorkerThreshold not correctly mapped") - } - if runtime.SlowWorkerGracePeriod != settings.Performance.SlowWorkerGracePeriod { - t.Error("SlowWorkerGracePeriod not correctly mapped") - } - if runtime.StallTimeout != settings.Performance.StallTimeout { - t.Error("StallTimeout not correctly mapped") - } - if runtime.SpeedEmaAlpha != settings.Performance.SpeedEmaAlpha { - t.Error("SpeedEmaAlpha not correctly mapped") - } -} - -// TestToRuntimeConfig_Exhaustive uses reflection to ensure that EVERY field -// in the target RuntimeConfig struct is populated by ToRuntimeConfig. -// This prevents "propagation gaps" when new fields are added to settings. -func TestToRuntimeConfig_Exhaustive(t *testing.T) { - settings := DefaultSettings() - - // Fill ALL network and performance settings with non-zero values - settings.Network.MaxConnectionsPerDownload = 1 - settings.Network.MaxConcurrentDownloads = 1 - settings.Network.MaxConcurrentProbes = 1 - settings.Network.UserAgent = "f" - settings.Network.ProxyURL = "g" - settings.Network.CustomDNS = "h" - settings.Network.SequentialDownload = true - settings.Network.MinChunkSize = 1 - settings.Network.WorkerBufferSize = 1 - settings.Network.DialHedgeCount = 1 - - settings.Performance.MaxTaskRetries = 1 - settings.Performance.SlowWorkerThreshold = 0.1 - settings.Performance.SlowWorkerGracePeriod = 1 * time.Second - settings.Performance.StallTimeout = 1 * time.Second - settings.Performance.SpeedEmaAlpha = 0.1 - - runtime := settings.ToRuntimeConfig() - - v := reflect.ValueOf(*runtime) - typeOfS := v.Type() - - for i := 0; i < v.NumField(); i++ { - field := v.Field(i) - fieldName := typeOfS.Field(i).Name - - // Ensure no field is zero-valued - if field.IsZero() { - t.Errorf("Field %q is zero in resulting RuntimeConfig. Did you forget to map it in Settings.ToRuntimeConfig?", fieldName) - } - } } func TestGetSettingsMetadata(t *testing.T) { @@ -478,41 +237,12 @@ func TestGetSettingsMetadata(t *testing.T) { t.Fatal("GetSettingsMetadata returned nil") } - // Verify all categories exist expectedCategories := CategoryOrder() for _, cat := range expectedCategories { if _, ok := metadata[cat]; !ok { t.Errorf("Missing metadata for category: %s", cat) } } - - // Verify each metadata entry has required fields - for category, settings := range metadata { - for i, setting := range settings { - if setting.Key == "" { - t.Errorf("Category %s, index %d: Key is empty", category, i) - } - if setting.Label == "" { - t.Errorf("Category %s, key %s: Label is empty", category, setting.Key) - } - if setting.Description == "" { - t.Errorf("Category %s, key %s: Description is empty", category, setting.Key) - } - if setting.Type == "" { - t.Errorf("Category %s, key %s: Type is empty", category, setting.Key) - } - - // Verify Type is valid - validTypes := map[string]bool{ - "string": true, "int": true, "int64": true, - "bool": true, "duration": true, "float64": true, - "auth_token": true, "link": true, - } - if !validTypes[setting.Type] { - t.Errorf("Category %s, key %s: Invalid type %q", category, setting.Key, setting.Type) - } - } - } } func TestCategoryOrder(t *testing.T) { @@ -521,233 +251,49 @@ func TestCategoryOrder(t *testing.T) { if len(order) == 0 { t.Error("CategoryOrder returned empty slice") } - - // Should have all expected categories - expectedCount := 5 // General, Network, Performance, Categories, Extension - if len(order) != expectedCount { - t.Errorf("Expected %d categories, got %d", expectedCount, len(order)) - } - - // Check for duplicates - seen := make(map[string]bool) - for _, cat := range order { - if seen[cat] { - t.Errorf("Duplicate category: %s", cat) - } - seen[cat] = true - } - - // Verify order matches metadata keys - metadata := GetSettingsMetadata() - for _, cat := range order { - if _, ok := metadata[cat]; !ok { - t.Errorf("Category %s in order but not in metadata", cat) - } - } } func TestSettingsJSON_Serialization(t *testing.T) { original := DefaultSettings() - // Serialize data, err := json.Marshal(original) if err != nil { t.Fatalf("Failed to marshal: %v", err) } - // Deserialize - loaded := &Settings{} + // Start with DefaultSettings() to ensure the struct schema is fully pre-populated + loaded := DefaultSettings() if err := json.Unmarshal(data, loaded); err != nil { t.Fatalf("Failed to unmarshal: %v", err) } - // Verify round-trip - if loaded.Network.MaxConnectionsPerDownload != original.Network.MaxConnectionsPerDownload { + if loaded.Network.MaxConnectionsPerDownload.AsInt() != original.Network.MaxConnectionsPerDownload.AsInt() { t.Error("Round-trip failed for MaxConnectionsPerDownload") } - if loaded.Performance.StallTimeout != original.Performance.StallTimeout { - t.Error("Round-trip failed for StallTimeout (duration)") - } -} - -func TestConstants(t *testing.T) { - // Verify KB and MB constants - if KB != 1024 { - t.Errorf("KB should be 1024, got %d", KB) - } - if MB != 1024*1024 { - t.Errorf("MB should be 1048576, got %d", MB) - } } func TestSaveSettings_RealFunction(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) original := DefaultSettings() - original.Network.MaxConnectionsPerDownload = 48 - original.General.AutoResume = true - original.Network.UserAgent = "TestAgent/3.0" + original.Network.MaxConnectionsPerDownload.Value = 48 + original.General.AutoResume.Value = true err := SaveSettings(original) if err != nil { t.Fatalf("SaveSettings failed: %v", err) } - // Verify file was created at expected path - settingsPath := GetSettingsPath() - if _, err := os.Stat(settingsPath); os.IsNotExist(err) { - t.Error("Settings file was not created by SaveSettings") - } - - // Now test LoadSettings to read it back loaded, err := LoadSettings() if err != nil { t.Fatalf("LoadSettings failed: %v", err) } - // Verify values match - if loaded.Network.MaxConnectionsPerDownload != 48 { - t.Errorf("MaxConnectionsPerDownload mismatch: got %d, want 48", loaded.Network.MaxConnectionsPerDownload) + if loaded.Network.MaxConnectionsPerDownload.AsInt() != 48 { + t.Errorf("MaxConnectionsPerDownload mismatch: got %d, want 48", loaded.Network.MaxConnectionsPerDownload.AsInt()) } - if !loaded.General.AutoResume { + if !loaded.General.AutoResume.AsBool() { t.Error("AutoResume should be true") } - if loaded.Network.UserAgent != "TestAgent/3.0" { - t.Errorf("UserAgent mismatch: got %q, want %q", loaded.Network.UserAgent, "TestAgent/3.0") - } - - // Cleanup: restore defaults - _ = SaveSettings(DefaultSettings()) -} - -func TestLoadSettings_RealFunction(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - // Test LoadSettings actually reads from disk - // First save something - original := DefaultSettings() - original.Performance.MaxTaskRetries = 9 - err := SaveSettings(original) - if err != nil { - t.Fatalf("SaveSettings failed: %v", err) - } - - // Now load it - loaded, err := LoadSettings() - if err != nil { - t.Fatalf("LoadSettings failed: %v", err) - } - - if loaded.Performance.MaxTaskRetries != 9 { - t.Errorf("MaxTaskRetries mismatch: got %d, want 9", loaded.Performance.MaxTaskRetries) - } - - // Cleanup - _ = SaveSettings(DefaultSettings()) -} - -func TestSaveAndLoadSettings_PreservesEmptyCategories(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - - settings := DefaultSettings() - settings.Categories.Categories = []Category{} - - if err := SaveSettings(settings); err != nil { - t.Fatalf("SaveSettings failed: %v", err) - } - - data, err := os.ReadFile(GetSettingsPath()) - if err != nil { - t.Fatalf("read settings file: %v", err) - } - if !strings.Contains(string(data), `"categories": []`) { - t.Fatalf("expected explicit empty categories array in settings.json, got: %s", string(data)) - } - - loaded, err := LoadSettings() - if err != nil { - t.Fatalf("LoadSettings failed: %v", err) - } - - if loaded.Categories.Categories == nil { - t.Fatal("expected categories slice to be non-nil after load") - } - if len(loaded.Categories.Categories) != 0 { - t.Fatalf("expected zero categories after reload, got %d", len(loaded.Categories.Categories)) - } -} - -func TestSaveAndLoadSettings_RoundTrip(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - // Test complete round trip via real functions - original := &Settings{ - General: GeneralSettings{ - DefaultDownloadDir: "/test/path", - WarnOnDuplicate: false, - AutoResume: true, - }, - Extension: ExtensionSettings{ - ExtensionPrompt: true, - }, - Network: NetworkSettings{ - MaxConnectionsPerDownload: 64, - UserAgent: "RoundTripTest/1.0", - SequentialDownload: true, - MinChunkSize: 1 * MB, - WorkerBufferSize: 1 * MB, - }, - Performance: PerformanceSettings{ - MaxTaskRetries: 10, - SlowWorkerThreshold: 0.2, - SlowWorkerGracePeriod: 15 * time.Second, - StallTimeout: 10 * time.Second, - SpeedEmaAlpha: 0.5, - }, - } - - // Save - err := SaveSettings(original) - if err != nil { - t.Fatalf("SaveSettings failed: %v", err) - } - - // Load - loaded, err := LoadSettings() - if err != nil { - t.Fatalf("LoadSettings failed: %v", err) - } - - // Verify all fields - if loaded.General.WarnOnDuplicate != original.General.WarnOnDuplicate { - t.Error("WarnOnDuplicate mismatch") - } - if loaded.Extension.ExtensionPrompt != original.Extension.ExtensionPrompt { - t.Error("ExtensionPrompt mismatch") - } - - if loaded.Network.SequentialDownload != original.Network.SequentialDownload { - t.Error("SequentialDownload mismatch") - } - if loaded.Performance.SlowWorkerGracePeriod != original.Performance.SlowWorkerGracePeriod { - t.Error("SlowWorkerGracePeriod mismatch") - } - - // Cleanup - _ = SaveSettings(DefaultSettings()) -} - -func TestDefaultSettings_Fallback(t *testing.T) { - // Unset XDG_DOWNLOAD_DIR - t.Setenv("XDG_DOWNLOAD_DIR", "") - - // We can't easily unset HOME or delete ~/Downloads in a test without affecting the system user or mocking os functions. - // But we can verify that the result is either empty or a valid directory. - settings := DefaultSettings() - dir := settings.General.DefaultDownloadDir - - if dir != "" { - if info, err := os.Stat(dir); err != nil || !info.IsDir() { - t.Errorf("DefaultDownloadDir fallback returned invalid path: %s", dir) - } - } } func TestSettings_Validate(t *testing.T) { @@ -761,126 +307,41 @@ func TestSettings_Validate(t *testing.T) { { name: "Valid Settings Unchanged", modify: func(s *Settings) { - s.Network.MaxConnectionsPerDownload = 48 - s.General.LogRetentionCount = 10 - s.Performance.SlowWorkerThreshold = 0.5 + s.Network.MaxConnectionsPerDownload.Value = 48 + s.General.LogRetentionCount.Value = 10 + s.Performance.SlowWorkerThreshold.Value = 0.5 }, validate: func(t *testing.T, s *Settings) { - if s.Network.MaxConnectionsPerDownload != 48 { - t.Errorf("Expected 48, got %d", s.Network.MaxConnectionsPerDownload) + if s.Network.MaxConnectionsPerDownload.AsInt() != 48 { + t.Errorf("Expected 48, got %d", s.Network.MaxConnectionsPerDownload.AsInt()) } - if s.General.LogRetentionCount != 10 { - t.Errorf("Expected 10, got %d", s.General.LogRetentionCount) + if s.General.LogRetentionCount.AsInt() != 10 { + t.Errorf("Expected 10, got %d", s.General.LogRetentionCount.AsInt()) } - if s.Performance.SlowWorkerThreshold != 0.5 { - t.Errorf("Expected 0.5, got %f", s.Performance.SlowWorkerThreshold) + if s.Performance.SlowWorkerThreshold.AsFloat64() != 0.5 { + t.Errorf("Expected 0.5, got %f", s.Performance.SlowWorkerThreshold.AsFloat64()) } }, }, { name: "Invalid Connections High Reset", modify: func(s *Settings) { - s.Network.MaxConnectionsPerDownload = 999 + s.Network.MaxConnectionsPerDownload.Value = 999 }, validate: func(t *testing.T, s *Settings) { - if s.Network.MaxConnectionsPerDownload != defaults.Network.MaxConnectionsPerDownload { - t.Errorf("Expected default %d, got %d", defaults.Network.MaxConnectionsPerDownload, s.Network.MaxConnectionsPerDownload) + if s.Network.MaxConnectionsPerDownload.AsInt() != defaults.Network.MaxConnectionsPerDownload.AsInt() { + t.Errorf("Expected default %d, got %d", defaults.Network.MaxConnectionsPerDownload.AsInt(), s.Network.MaxConnectionsPerDownload.AsInt()) } }, }, { name: "Invalid Connections Low Reset", modify: func(s *Settings) { - s.Network.MaxConnectionsPerDownload = 0 + s.Network.MaxConnectionsPerDownload.Value = 0 }, validate: func(t *testing.T, s *Settings) { - if s.Network.MaxConnectionsPerDownload != defaults.Network.MaxConnectionsPerDownload { - t.Errorf("Expected default %d, got %d", defaults.Network.MaxConnectionsPerDownload, s.Network.MaxConnectionsPerDownload) - } - }, - }, - { - name: "Invalid Concurrent Downloads Reset", - modify: func(s *Settings) { - s.Network.MaxConcurrentDownloads = 15 - }, - validate: func(t *testing.T, s *Settings) { - if s.Network.MaxConcurrentDownloads != defaults.Network.MaxConcurrentDownloads { - t.Errorf("Expected default %d, got %d", defaults.Network.MaxConcurrentDownloads, s.Network.MaxConcurrentDownloads) - } - }, - }, - { - name: "Invalid Retention Count Reset", - modify: func(s *Settings) { - s.General.LogRetentionCount = 0 - }, - validate: func(t *testing.T, s *Settings) { - if s.General.LogRetentionCount != defaults.General.LogRetentionCount { - t.Errorf("Expected default %d, got %d", defaults.General.LogRetentionCount, s.General.LogRetentionCount) - } - }, - }, - { - name: "Invalid Threshold Reset", - modify: func(s *Settings) { - s.Performance.SlowWorkerThreshold = 1.5 - }, - validate: func(t *testing.T, s *Settings) { - if s.Performance.SlowWorkerThreshold != defaults.Performance.SlowWorkerThreshold { - t.Errorf("Expected default %f, got %f", defaults.Performance.SlowWorkerThreshold, s.Performance.SlowWorkerThreshold) - } - }, - }, - { - name: "Invalid Duration Reset", - modify: func(s *Settings) { - s.Performance.SlowWorkerGracePeriod = -1 * time.Second - }, - validate: func(t *testing.T, s *Settings) { - if s.Performance.SlowWorkerGracePeriod != defaults.Performance.SlowWorkerGracePeriod { - t.Errorf("Expected default, got %v", s.Performance.SlowWorkerGracePeriod) - } - }, - }, - { - name: "Broken Path Reset", - modify: func(s *Settings) { - s.General.DefaultDownloadDir = "/non/existent/path/that/should/fail" - }, - validate: func(t *testing.T, s *Settings) { - if s.General.DefaultDownloadDir != defaults.General.DefaultDownloadDir { - t.Errorf("Expected fallback to %q, got %q", defaults.General.DefaultDownloadDir, s.General.DefaultDownloadDir) - } - }, - }, - { - name: "Broken Category Regex Removal", - modify: func(s *Settings) { - s.Categories.Categories = []Category{ - {Name: "Broken", Pattern: "[", Path: "/tmp"}, - {Name: "Valid", Pattern: ".*", Path: "/tmp"}, - } - }, - validate: func(t *testing.T, s *Settings) { - if len(s.Categories.Categories) != 1 { - t.Errorf("Expected 1 valid category, got %d", len(s.Categories.Categories)) - } - if s.Categories.Categories[0].Name != "Valid" { - t.Errorf("Expected 'Valid' category, got %q", s.Categories.Categories[0].Name) - } - }, - }, - { - name: "All Broken Categories Removal", - modify: func(s *Settings) { - s.Categories.Categories = []Category{ - {Name: "Broken", Pattern: "[", Path: "/tmp"}, - } - }, - validate: func(t *testing.T, s *Settings) { - if len(s.Categories.Categories) != 0 { - t.Errorf("Expected 0 categories, got %d", len(s.Categories.Categories)) + if s.Network.MaxConnectionsPerDownload.AsInt() != defaults.Network.MaxConnectionsPerDownload.AsInt() { + t.Errorf("Expected default %d, got %d", defaults.Network.MaxConnectionsPerDownload.AsInt(), s.Network.MaxConnectionsPerDownload.AsInt()) } }, }, @@ -895,30 +356,3 @@ func TestSettings_Validate(t *testing.T) { }) } } - -func TestSettings_FutureProofValidation(t *testing.T) { - s := Settings{} - v := reflect.ValueOf(s) - tpe := v.Type() - - for i := 0; i < tpe.NumField(); i++ { - field := tpe.Field(i) - // Skip unexported fields or non-struct fields - if field.PkgPath != "" || field.Type.Kind() != reflect.Struct { - continue - } - - // Ensure the field type has a Validate method - // Some might take parameters (like Categories), some don't. - // We just check if a method named "Validate" exists. - _, ok := field.Type.MethodByName("Validate") - if !ok { - // If the type itself doesn't have it, check if a pointer to it does - _, ok = reflect.PointerTo(field.Type).MethodByName("Validate") - } - - if !ok { - t.Errorf("Field %s (type %s) does not have a Validate method. Every settings group MUST implement validation to ensure application stability.", field.Name, field.Type.Name()) - } - } -} diff --git a/internal/core/local_service.go b/internal/core/local_service.go index ab099018..4239f54b 100644 --- a/internal/core/local_service.go +++ b/internal/core/local_service.go @@ -272,7 +272,7 @@ func (s *LocalDownloadService) getSpeedEmaAlpha() float64 { return SpeedSmoothingAlpha } - alpha := settings.Performance.SpeedEmaAlpha + alpha := settings.Performance.SpeedEmaAlpha.AsFloat64() if alpha <= 0 || alpha > 1 { return SpeedSmoothingAlpha } @@ -476,8 +476,8 @@ func (s *LocalDownloadService) add(url string, path string, filename string, mir outPath := path if outPath == "" { - if settings.General.DefaultDownloadDir != "" { - outPath = settings.General.DefaultDownloadDir + if settings.General.DefaultDownloadDir.AsString() != "" { + outPath = settings.General.DefaultDownloadDir.AsString() } else { outPath = "." } diff --git a/internal/core/pause_resume_integration_test.go b/internal/core/pause_resume_integration_test.go index 2712f296..e16b8dc8 100644 --- a/internal/core/pause_resume_integration_test.go +++ b/internal/core/pause_resume_integration_test.go @@ -145,9 +145,9 @@ func forceSingleConnectionRuntime(svc *LocalDownloadService) { // Keep integration behavior deterministic: // - single worker connection (no hedging/stealing overlap effects), // - conservative health settings to avoid synthetic task cancellation. - svc.settings.Network.MaxConnectionsPerDownload = 1 - svc.settings.Performance.SlowWorkerGracePeriod = 60 * time.Second - svc.settings.Performance.StallTimeout = 60 * time.Second + svc.settings.Network.MaxConnectionsPerDownload.Value = 1 + svc.settings.Performance.SlowWorkerGracePeriod.Value = 60 * time.Second + svc.settings.Performance.StallTimeout.Value = 60 * time.Second } func TestIntegration_PauseResume_HotPath_Aggregates(t *testing.T) { diff --git a/internal/engine/types/accuracy_test.go b/internal/engine/types/accuracy_test.go index 27c939aa..33b8f57f 100644 --- a/internal/engine/types/accuracy_test.go +++ b/internal/engine/types/accuracy_test.go @@ -94,7 +94,7 @@ func TestRestoreBitmap_ShortBitmapRecoversWithoutPanic(t *testing.T) { state := types.NewProgressState("test-short-restore", totalSize) malformed := []byte{0x02} // Too short: only enough storage for 4 chunks. - expectedBytes := 25 // 100 chunks * 2 bits = 25 bytes. + expectedBytes := 25 // 100 chunks * 2 bits = 25 bytes. defer func() { if r := recover(); r != nil { diff --git a/internal/processing/events.go b/internal/processing/events.go index 673aeccc..9fd3f012 100644 --- a/internal/processing/events.go +++ b/internal/processing/events.go @@ -247,7 +247,7 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { if err != nil { msg = err.Error() } - if settings := mgr.GetSettings(); settings != nil && settings.General.DownloadCompleteNotification { + if settings := mgr.GetSettings(); settings != nil && settings.General.DownloadCompleteNotification.AsBool() { notify(fmt.Sprintf("Download failed: %s", filename), msg) } break @@ -271,7 +271,7 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { if err := state.DeleteTasks(m.DownloadID); err != nil { utils.Debug("Lifecycle: Failed to delete completed tasks: %v", err) } - if settings := mgr.GetSettings(); settings != nil && settings.General.DownloadCompleteNotification { + if settings := mgr.GetSettings(); settings != nil && settings.General.DownloadCompleteNotification.AsBool() { if filename == "" { filename = m.Filename @@ -306,7 +306,7 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { utils.Debug("Lifecycle: Failed to remove incomplete file after error: %v", err) } } - if settings := mgr.GetSettings(); settings != nil && settings.General.DownloadCompleteNotification { + if settings := mgr.GetSettings(); settings != nil && settings.General.DownloadCompleteNotification.AsBool() { filename := m.Filename if filename == "" && existing != nil { diff --git a/internal/processing/events_internal_test.go b/internal/processing/events_internal_test.go index 942db437..d5a4c4d1 100644 --- a/internal/processing/events_internal_test.go +++ b/internal/processing/events_internal_test.go @@ -105,7 +105,7 @@ func TestStartEventWorker_MarksCompletionAsErrorWhenFinalizationFails(t *testing settingsDir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", settingsDir) settings := config.DefaultSettings() - settings.General.DownloadCompleteNotification = true + settings.General.DownloadCompleteNotification.Value = true if err := config.SaveSettings(settings); err != nil { t.Fatalf("failed to save settings: %v", err) } @@ -198,7 +198,7 @@ func TestStartEventWorker_SuppressesNotificationWhenSettingDisabled(t *testing.T settingsDir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", settingsDir) settings := config.DefaultSettings() - settings.General.DownloadCompleteNotification = false + settings.General.DownloadCompleteNotification.Value = false if err := config.SaveSettings(settings); err != nil { t.Fatalf("failed to save settings: %v", err) } @@ -248,7 +248,7 @@ func TestStartEventWorker_CompletionNotificationUsesGenericMessageWhenElapsedZer settingsDir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", settingsDir) settings := config.DefaultSettings() - settings.General.DownloadCompleteNotification = true + settings.General.DownloadCompleteNotification.Value = true if err := config.SaveSettings(settings); err != nil { t.Fatalf("failed to save settings: %v", err) } @@ -293,7 +293,7 @@ func TestStartEventWorker_ErrorNotificationFallsBackToDownloadID(t *testing.T) { settingsDir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", settingsDir) settings := config.DefaultSettings() - settings.General.DownloadCompleteNotification = true + settings.General.DownloadCompleteNotification.Value = true if err := config.SaveSettings(settings); err != nil { t.Fatalf("failed to save settings: %v", err) } diff --git a/internal/processing/file_utils.go b/internal/processing/file_utils.go index d871e241..12356b72 100644 --- a/internal/processing/file_utils.go +++ b/internal/processing/file_utils.go @@ -138,7 +138,7 @@ func GetUniqueFilename(dir, filename string, isNameActive func(string, string) b // GetCategoryPath applies category routing only while the caller is still using // the default destination, so explicit user paths are left untouched. func GetCategoryPath(filename, defaultDir string, settings *config.Settings) (string, error) { - if settings == nil || !settings.Categories.CategoryEnabled || filename == "" { + if settings == nil || !settings.Categories.CategoryEnabled.AsBool() || filename == "" { return defaultDir, nil } @@ -176,7 +176,7 @@ func ResolveDestination(url, candidateFilename, defaultDir string, routeToCatego filename := getBaseFilename(url, candidateFilename, probe) destPath := defaultDir - if routeToCategory && settings != nil && settings.Categories.CategoryEnabled && filename != "" { + if routeToCategory && settings != nil && settings.Categories.CategoryEnabled.AsBool() && filename != "" { var err error destPath, err = GetCategoryPath(filename, defaultDir, settings) if err != nil { diff --git a/internal/processing/file_utils_test.go b/internal/processing/file_utils_test.go index 93dd14f4..7ed8e02c 100644 --- a/internal/processing/file_utils_test.go +++ b/internal/processing/file_utils_test.go @@ -101,7 +101,7 @@ func TestGetCategoryPath(t *testing.T) { tmpDir := t.TempDir() settings := config.DefaultSettings() - settings.Categories.CategoryEnabled = true + settings.Categories.CategoryEnabled.Value = true settings.Categories.Categories = []config.Category{ { Name: "Images", @@ -130,7 +130,7 @@ func TestGetCategoryPath(t *testing.T) { } // Disabled - settings.Categories.CategoryEnabled = false + settings.Categories.CategoryEnabled.Value = false path, err = processing.GetCategoryPath("test.jpg", tmpDir, settings) if err != nil { t.Fatalf("Unexpected error: %v", err) @@ -141,7 +141,7 @@ func TestGetCategoryPath(t *testing.T) { // No side effects: routing should not create the directory before reservation. missingDir := filepath.Join(tmpDir, "missing") - settings.Categories.CategoryEnabled = true + settings.Categories.CategoryEnabled.Value = true settings.Categories.Categories = []config.Category{ { Name: "Programs", @@ -163,7 +163,7 @@ func TestGetCategoryPath(t *testing.T) { func TestResolveDestination_Priority(t *testing.T) { settings := config.DefaultSettings() - settings.Categories.CategoryEnabled = false + settings.Categories.CategoryEnabled.Value = false defaultDir := "/downloads" // 1. User defined beats all @@ -213,7 +213,7 @@ func TestResolveDestination_Priority(t *testing.T) { func TestResolveDestination_ErrorsWhenUniqueNameExhausted(t *testing.T) { settings := config.DefaultSettings() - settings.Categories.CategoryEnabled = false + settings.Categories.CategoryEnabled.Value = false overflowActive := func(dir, name string) bool { if name == "overflow.bin" { diff --git a/internal/processing/manager.go b/internal/processing/manager.go index def4710f..ce033b29 100644 --- a/internal/processing/manager.go +++ b/internal/processing/manager.go @@ -96,8 +96,8 @@ func NewLifecycleManager(addFunc AddDownloadFunc, addWithIDFunc AddDownloadWithI } probeCap := defaultMaxConcurrentProbes - if settings != nil && settings.Network.MaxConcurrentProbes > 0 { - probeCap = settings.Network.MaxConcurrentProbes + if settings != nil && settings.Network.MaxConcurrentProbes.AsInt() > 0 { + probeCap = settings.Network.MaxConcurrentProbes.AsInt() } sem := make(chan struct{}, probeCap) for i := 0; i < probeCap; i++ { diff --git a/internal/processing/manager_test.go b/internal/processing/manager_test.go index d4740afa..f82c2161 100644 --- a/internal/processing/manager_test.go +++ b/internal/processing/manager_test.go @@ -37,7 +37,7 @@ func newProbeTestServer(t *testing.T, size int64) *httptest.Server { func newLifecycleManagerForTest() *LifecycleManager { settings := config.DefaultSettings() - settings.Categories.CategoryEnabled = false + settings.Categories.CategoryEnabled.Value = false sem := make(chan struct{}, maxConcurrentProbes) for i := 0; i < maxConcurrentProbes; i++ { sem <- struct{}{} @@ -461,7 +461,7 @@ func TestLifecycleManager_GetSettings_RefreshesFromDiskAfterTTL(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmpDir) initial := config.DefaultSettings() - initial.Categories.CategoryEnabled = false + initial.Categories.CategoryEnabled.Value = false if err := config.SaveSettings(initial); err != nil { t.Fatalf("SaveSettings(initial) failed: %v", err) } @@ -469,7 +469,7 @@ func TestLifecycleManager_GetSettings_RefreshesFromDiskAfterTTL(t *testing.T) { mgr := NewLifecycleManager(nil, nil) updated := config.DefaultSettings() - updated.Categories.CategoryEnabled = true + updated.Categories.CategoryEnabled.Value = true if err := config.SaveSettings(updated); err != nil { t.Fatalf("SaveSettings(updated) failed: %v", err) } @@ -481,7 +481,7 @@ func TestLifecycleManager_GetSettings_RefreshesFromDiskAfterTTL(t *testing.T) { settingsRefreshTTL = 0 settings := mgr.GetSettings() - if !settings.Categories.CategoryEnabled { + if !settings.Categories.CategoryEnabled.AsBool() { t.Fatal("expected GetSettings to pick up saved settings after TTL expiry") } } @@ -491,7 +491,7 @@ func TestLifecycleManager_GetSettings_KeepsCachedSnapshotWhenReloadFails(t *test t.Setenv("XDG_CONFIG_HOME", tmpDir) initial := config.DefaultSettings() - initial.General.WarnOnDuplicate = false + initial.General.WarnOnDuplicate.Value = false if err := config.SaveSettings(initial); err != nil { t.Fatalf("SaveSettings(initial) failed: %v", err) } @@ -511,7 +511,7 @@ func TestLifecycleManager_GetSettings_KeepsCachedSnapshotWhenReloadFails(t *test settingsRefreshTTL = 0 settings := mgr.GetSettings() - if settings.General.WarnOnDuplicate { + if settings.General.WarnOnDuplicate.AsBool() { t.Fatal("expected GetSettings to keep the cached snapshot when disk reload fails") } } @@ -1076,7 +1076,7 @@ func TestLifecycleManager_ProbeSemaphore_LimitsInflight(t *testing.T) { return "", fmt.Errorf("dispatch intentionally rejected for test") } settings := config.DefaultSettings() - settings.Categories.CategoryEnabled = false + settings.Categories.CategoryEnabled.Value = false mgr.ApplySettings(settings) tempDir := t.TempDir() diff --git a/internal/processing/pause_resume.go b/internal/processing/pause_resume.go index 9122ce11..53a1c112 100644 --- a/internal/processing/pause_resume.go +++ b/internal/processing/pause_resume.go @@ -125,7 +125,7 @@ func (mgr *LifecycleManager) Resume(id string) error { settings := mgr.GetSettings() - outputPath := settings.General.DefaultDownloadDir + outputPath := settings.General.DefaultDownloadDir.AsString() if outputPath == "" { outputPath = "." } @@ -156,7 +156,7 @@ func (mgr *LifecycleManager) ResumeBatch(ids []string) []error { hooks := mgr.getEngineHooks() settings := mgr.GetSettings() - outputPath := settings.General.DefaultDownloadDir + outputPath := settings.General.DefaultDownloadDir.AsString() if outputPath == "" { outputPath = "." } diff --git a/internal/processing/probe_test.go b/internal/processing/probe_test.go index 5354af0c..034e2d4f 100644 --- a/internal/processing/probe_test.go +++ b/internal/processing/probe_test.go @@ -32,7 +32,7 @@ func TestProbeServer_UsesConfiguredProxy(t *testing.T) { defer proxy.Close() settings := config.DefaultSettings() - settings.Network.ProxyURL = proxy.URL + settings.Network.ProxyURL.Value = proxy.URL if err := config.SaveSettings(settings); err != nil { t.Fatalf("SaveSettings() error = %v", err) } diff --git a/internal/tui/autoresume_test.go b/internal/tui/autoresume_test.go index 43f634d0..14da5d68 100644 --- a/internal/tui/autoresume_test.go +++ b/internal/tui/autoresume_test.go @@ -30,8 +30,8 @@ func TestAutoResume_Enabled(t *testing.T) { // 2. Create settings file with AutoResume = true settingsPath := filepath.Join(surgeDir, "settings.json") settings := config.DefaultSettings() - settings.General.AutoResume = true - settings.General.DefaultDownloadDir = tmpDir + settings.General.AutoResume.Value = true + settings.General.DefaultDownloadDir.Value = tmpDir data, _ := json.Marshal(settings) if err := os.WriteFile(settingsPath, data, 0o644); err != nil { @@ -103,7 +103,7 @@ func TestAutoResume_Disabled(t *testing.T) { // 2. Settings with AutoResume = false settingsPath := filepath.Join(surgeDir, "settings.json") settings := config.DefaultSettings() - settings.General.AutoResume = false + settings.General.AutoResume.Value = false data, _ := json.Marshal(settings) if err := os.WriteFile(settingsPath, data, 0o644); err != nil { diff --git a/internal/tui/category_regressions_test.go b/internal/tui/category_regressions_test.go index 82a4ca46..353403ab 100644 --- a/internal/tui/category_regressions_test.go +++ b/internal/tui/category_regressions_test.go @@ -31,8 +31,8 @@ func TestStartDownload_RoutesDefaultPathWithURLDerivedFilename(t *testing.T) { imageDir := filepath.Join(rootDir, "images") settings := config.DefaultSettings() - settings.Categories.CategoryEnabled = true - settings.General.DefaultDownloadDir = rootDir + settings.Categories.CategoryEnabled.Value = true + settings.General.DefaultDownloadDir.Value = rootDir settings.Categories.Categories = []config.Category{ {Name: "Images", Pattern: `(?i)\.(jpg|jpeg|png)$`, Path: imageDir}, } @@ -53,9 +53,9 @@ func TestUpdate_InputSubmit_BlankPathUsesDefaultPathRouting(t *testing.T) { musicDir := filepath.Join(rootDir, "music") settings := config.DefaultSettings() - settings.Categories.CategoryEnabled = true - settings.General.WarnOnDuplicate = false - settings.General.DefaultDownloadDir = rootDir + settings.Categories.CategoryEnabled.Value = true + settings.General.WarnOnDuplicate.Value = false + settings.General.DefaultDownloadDir.Value = rootDir settings.Categories.Categories = []config.Category{ {Name: "Music", Pattern: `(?i)\.(mp3|flac)$`, Path: musicDir}, } @@ -83,8 +83,8 @@ func TestUpdate_DuplicateContinuePreservesDefaultPathRouting(t *testing.T) { videoDir := filepath.Join(rootDir, "videos") settings := config.DefaultSettings() - settings.Categories.CategoryEnabled = true - settings.General.DefaultDownloadDir = rootDir + settings.Categories.CategoryEnabled.Value = true + settings.General.DefaultDownloadDir.Value = rootDir settings.Categories.Categories = []config.Category{ {Name: "Videos", Pattern: `(?i)\.mp4$`, Path: videoDir}, } @@ -112,9 +112,9 @@ func TestUpdate_ExtensionConfirmBlankPathUsesDefaultPathRouting(t *testing.T) { docDir := filepath.Join(rootDir, "docs") settings := config.DefaultSettings() - settings.Categories.CategoryEnabled = true - settings.General.WarnOnDuplicate = false - settings.General.DefaultDownloadDir = rootDir + settings.Categories.CategoryEnabled.Value = true + settings.General.WarnOnDuplicate.Value = false + settings.General.DefaultDownloadDir.Value = rootDir settings.Categories.Categories = []config.Category{ {Name: "Documents", Pattern: `(?i)\.pdf$`, Path: docDir}, } @@ -171,7 +171,7 @@ func TestUpdate_CategoryManagerEscRemovesNewPlaceholder(t *testing.T) { func TestGetFilteredDownloads_AppliesCategoryFilter(t *testing.T) { settings := config.DefaultSettings() - settings.Categories.CategoryEnabled = true + settings.Categories.CategoryEnabled.Value = true settings.Categories.Categories = []config.Category{ {Name: "Videos", Pattern: `(?i)\.mp4$`}, {Name: "Documents", Pattern: `(?i)\.pdf$`}, diff --git a/internal/tui/helpers.go b/internal/tui/helpers.go index 5ddd7323..21c29437 100644 --- a/internal/tui/helpers.go +++ b/internal/tui/helpers.go @@ -74,14 +74,14 @@ func (m *RootModel) removeDownloadByID(id string) bool { func (m *RootModel) handleFilePickerSelection(path string) (tea.Model, tea.Cmd) { switch m.filepickerOrigin { case FilePickerOriginTheme: - m.Settings.General.ThemePath = path - m.ApplyTheme(m.Settings.General.Theme, path) + m.Settings.General.ThemePath.Value = path + m.ApplyTheme(m.Settings.General.Theme.AsInt(), path) m.filepickerOrigin = FilePickerOriginNone m.state = SettingsState m.resetFilepickerToDirMode() return m, nil case FilePickerOriginSettings: - m.Settings.General.DefaultDownloadDir = path + m.Settings.General.DefaultDownloadDir.Value = path m.filepickerOrigin = FilePickerOriginNone m.state = SettingsState m.resetFilepickerToDirMode() @@ -114,7 +114,7 @@ func (m *RootModel) handleFilePickerGotoHome() tea.Cmd { if m.filepickerOrigin == FilePickerOriginTheme { targetDir = config.GetThemesDir() } else { - targetDir = m.Settings.General.DefaultDownloadDir + targetDir = m.Settings.General.DefaultDownloadDir.AsString() if targetDir == "" { homeDir, _ := os.UserHomeDir() targetDir = filepath.Join(homeDir, "Downloads") @@ -185,10 +185,42 @@ func (m *RootModel) snapshotSettings() { if m.Settings == nil { return } - // Shallow copy settings to compare restart-required fields later. - // Since Settings is a pointer, we clone the underlying struct. - baseline := *m.Settings - m.SettingsBaseline = &baseline + // Deep clone settings to compare restart-required fields later. + m.SettingsBaseline = m.Settings.Clone() +} + +func settingsEqual(s1, s2 *config.Setting) bool { + if s1 == nil || s2 == nil { + return s1 == s2 + } + // If both are numbers, compare their float64 representations to handle JSON deserialization type differences (int vs float64) + switch s1.Type { + case "int", "int64", "float64", "duration": + var v1, v2 float64 + switch val := s1.Value.(type) { + case int: + v1 = float64(val) + case int64: + v1 = float64(val) + case float64: + v1 = val + case time.Duration: + v1 = float64(val) + } + switch val := s2.Value.(type) { + case int: + v2 = float64(val) + case int64: + v2 = float64(val) + case float64: + v2 = val + case time.Duration: + v2 = float64(val) + } + return v1 == v2 + default: + return reflect.DeepEqual(s1.Value, s2.Value) + } } func (m *RootModel) checkRestartRequirement() bool { @@ -209,12 +241,15 @@ func (m *RootModel) checkRestartRequirement() bool { catTyp := catField1.Type() for j := 0; j < catTyp.NumField(); j++ { - field := catTyp.Field(j) - if field.Tag.Get("ui_restart") == "true" { - f1 := catField1.Field(j) - f2 := catField2.Field(j) - if !reflect.DeepEqual(f1.Interface(), f2.Interface()) { - return true + f1 := catField1.Field(j) + f2 := catField2.Field(j) + s1, ok1 := f1.Interface().(*config.Setting) + s2, ok2 := f2.Interface().(*config.Setting) + if ok1 && ok2 && s1 != nil && s2 != nil { + if s1.NeedsRestart { + if !settingsEqual(s1, s2) { + return true + } } } } diff --git a/internal/tui/model.go b/internal/tui/model.go index 1c7d77ee..7ccacb76 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -301,10 +301,10 @@ func InitialRootModel(serverPort int, currentVersion string, service core.Downlo // Override AutoResume if CLI flag provided if noResume { - settings.General.AutoResume = false + settings.General.AutoResume.Value = false } - applyColorModeForTheme(settings.General.Theme, settings.General.ThemePath, initialDarkBackground) + applyColorModeForTheme(settings.General.Theme.AsInt(), settings.General.ThemePath.AsString(), initialDarkBackground) // Load paused downloads from master list (now uses global config directory) var downloads []*DownloadModel @@ -338,7 +338,7 @@ func InitialRootModel(serverPort int, currentVersion string, service core.Downlo dm.pausing = true dm.started = true case "paused": - if settings.General.AutoResume { + if settings.General.AutoResume.AsBool() { dm.resuming = true dm.paused = true // Will update when resume event received } else { @@ -491,7 +491,7 @@ func (m RootModel) Init() tea.Cmd { cmds = append(cmds, m.spinner.Tick) // Trigger update check if not disabled in settings - if !m.Settings.General.SkipUpdateCheck { + if !m.Settings.General.SkipUpdateCheck.AsBool() { cmds = append(cmds, checkForUpdateCmd(m.CurrentVersion)) } @@ -568,7 +568,7 @@ func (m RootModel) getFilteredDownloads() []*DownloadModel { } // Apply dashboard category filter. - if m.categoryFilter != "" && m.Settings != nil && m.Settings.Categories.CategoryEnabled { + if m.categoryFilter != "" && m.Settings != nil && m.Settings.Categories.CategoryEnabled.AsBool() { if !m.matchesCategoryFilter(d) { continue } diff --git a/internal/tui/process.go b/internal/tui/process.go index a93bd5dd..533911e1 100644 --- a/internal/tui/process.go +++ b/internal/tui/process.go @@ -57,7 +57,7 @@ func (m *RootModel) processProgressMsg(msg events.ProgressMsg) tea.Cmd { totalSpeed := m.calcTotalSpeed() // EMA smooth against previous graph point for visual continuity var smoothed float64 - if m.Settings != nil && m.Settings.General.LiveSpeedGraph { + if m.Settings != nil && m.Settings.General.LiveSpeedGraph.AsBool() { smoothed = totalSpeed } else if len(m.SpeedHistory) > 0 { prev := m.SpeedHistory[len(m.SpeedHistory)-1] @@ -220,7 +220,7 @@ func (m RootModel) startDownload(url string, mirrors []string, headers map[strin func (m RootModel) defaultDownloadPath() string { if m.Settings != nil { - if path := strings.TrimSpace(m.Settings.General.DefaultDownloadDir); path != "" { + if path := strings.TrimSpace(m.Settings.General.DefaultDownloadDir.AsString()); path != "" { return path } } diff --git a/internal/tui/resume_lifecycle_test.go b/internal/tui/resume_lifecycle_test.go index 61d5c8df..1f2663f4 100644 --- a/internal/tui/resume_lifecycle_test.go +++ b/internal/tui/resume_lifecycle_test.go @@ -44,7 +44,7 @@ func TestResume_RespectsOriginalPath_WhenDefaultChanges(t *testing.T) { // 2. Initialize Model with DefaultDir = DirA settings := config.DefaultSettings() - settings.General.DefaultDownloadDir = dirA + settings.General.DefaultDownloadDir.Value = dirA m := RootModel{ Settings: settings, @@ -122,7 +122,7 @@ func TestResume_RespectsOriginalPath_WhenDefaultChanges(t *testing.T) { } // 6. Change Settings (Default Dir = DirB) and CWD - settings.General.DefaultDownloadDir = dirB + settings.General.DefaultDownloadDir.Value = dirB if err := os.Chdir(dirB); err != nil { t.Fatal(err) } @@ -150,7 +150,7 @@ func TestResume_RespectsOriginalPath_WhenDefaultChanges(t *testing.T) { // Even if logic checks for empty/dot, filepath.Dir of absolute path is absolute path. if outputPath == "" || outputPath == "." { // This should NOT happen for absolute paths - outputPath = settings.General.DefaultDownloadDir + outputPath = settings.General.DefaultDownloadDir.AsString() } // Ensure outputPath resolves to DirA diff --git a/internal/tui/settings_reset_test.go b/internal/tui/settings_reset_test.go index b340b633..160e6416 100644 --- a/internal/tui/settings_reset_test.go +++ b/internal/tui/settings_reset_test.go @@ -43,24 +43,26 @@ func TestSettingsResetExhaustive(t *testing.T) { // setNonDefaultValue modifies a specific setting in the settings struct to a known "dirty" value. func setNonDefaultValue(t *testing.T, s *config.Settings, categoryLabel, jsonKey string) { field := getFieldByJsonKey(t, s, categoryLabel, jsonKey) + setting, ok := field.Interface().(*config.Setting) + if !ok || setting == nil { + t.Fatalf("Setting field %s is not a *config.Setting", jsonKey) + } - switch field.Kind() { - case reflect.Bool: - field.SetBool(!field.Bool()) - case reflect.String: - field.SetString("modified-value-" + jsonKey) - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32: - field.SetInt(field.Int() + 10) - case reflect.Int64: - if field.Type().String() == "time.Duration" { - field.Set(reflect.ValueOf(field.Interface().(time.Duration) + time.Hour)) - } else { - field.SetInt(field.Int() + 100) - } - case reflect.Float32, reflect.Float64: - field.SetFloat(field.Float() + 0.5) + switch val := setting.Value.(type) { + case bool: + setting.Value = !val + case string: + setting.Value = "modified-value-" + jsonKey + case int: + setting.Value = val + 10 + case int64: + setting.Value = val + 100 + case float64: + setting.Value = val + 0.5 + case time.Duration: + setting.Value = val + time.Hour default: - t.Errorf("Unsupported type for setting %s: %v", jsonKey, field.Kind()) + t.Errorf("Unsupported type for setting %s: %T", jsonKey, setting.Value) } } @@ -69,9 +71,15 @@ func verifyIsDefault(t *testing.T, actual, expected *config.Settings, categoryLa actualField := getFieldByJsonKey(t, actual, categoryLabel, jsonKey) expectedField := getFieldByJsonKey(t, expected, categoryLabel, jsonKey) - if !reflect.DeepEqual(actualField.Interface(), expectedField.Interface()) { + actSetting, actOk := actualField.Interface().(*config.Setting) + expSetting, expOk := expectedField.Interface().(*config.Setting) + if !actOk || !expOk || actSetting == nil || expSetting == nil { + t.Fatalf("Fields are not *config.Setting") + } + + if !reflect.DeepEqual(actSetting.Value, expSetting.Value) { t.Errorf("Setting %q in category %q was not reset to default.\nGot: %v\nWant: %v", - jsonKey, categoryLabel, actualField.Interface(), expectedField.Interface()) + jsonKey, categoryLabel, actSetting.Value, expSetting.Value) } } diff --git a/internal/tui/settings_restart_test.go b/internal/tui/settings_restart_test.go index fac28f99..a125e439 100644 --- a/internal/tui/settings_restart_test.go +++ b/internal/tui/settings_restart_test.go @@ -30,20 +30,20 @@ func TestRestartRequirementDetection(t *testing.T) { } // 4. Change non-restart setting (e.g. Theme) - originalTheme := m.Settings.General.Theme - m.Settings.General.Theme = (originalTheme + 1) % 3 + originalTheme := m.Settings.General.Theme.AsInt() + m.Settings.General.Theme.Value = (originalTheme + 1) % 3 if m.checkRestartRequirement() { t.Error("checkRestartRequirement() should be false when only non-restart settings changed") } // 5. Change restart-required setting (e.g. MaxConcurrentDownloads) - m.Settings.Network.MaxConcurrentDownloads += 1 + m.Settings.Network.MaxConcurrentDownloads.Value = m.Settings.Network.MaxConcurrentDownloads.AsInt() + 1 if !m.checkRestartRequirement() { t.Error("checkRestartRequirement() should be true when restart-required setting changed") } // 6. Reverting should make it false again - m.Settings.Network.MaxConcurrentDownloads -= 1 + m.Settings.Network.MaxConcurrentDownloads.Value = m.Settings.Network.MaxConcurrentDownloads.AsInt() - 1 if m.checkRestartRequirement() { t.Error("checkRestartRequirement() should be false when settings are reverted to baseline") } diff --git a/internal/tui/startup_test.go b/internal/tui/startup_test.go index 2554b11c..74e624c0 100644 --- a/internal/tui/startup_test.go +++ b/internal/tui/startup_test.go @@ -178,7 +178,7 @@ func setupTestEnv(t *testing.T, tmpDir string) { // Setup Settings (AutoResume=false default) settings := config.DefaultSettings() - settings.General.AutoResume = false // Ensure we test that "queued" overrides this + settings.General.AutoResume.Value = false // Ensure we test that "queued" overrides this if err := config.SaveSettings(settings); err != nil { t.Fatal(err) } diff --git a/internal/tui/update_category.go b/internal/tui/update_category.go index b09128b4..86cd4af4 100644 --- a/internal/tui/update_category.go +++ b/internal/tui/update_category.go @@ -124,7 +124,7 @@ func (m RootModel) updateCategoryManager(msg tea.KeyPressMsg) (tea.Model, tea.Cm originalPath := m.catMgrInputs[3].Value() browseDir := strings.TrimSpace(originalPath) if browseDir == "" { - browseDir = m.Settings.General.DefaultDownloadDir + browseDir = m.Settings.General.DefaultDownloadDir.AsString() } if browseDir == "" { browseDir = m.PWD @@ -238,7 +238,7 @@ func (m RootModel) updateCategoryManager(msg tea.KeyPressMsg) (tea.Model, tea.Cm } if key.Matches(msg, m.keys.CategoryMgr.Toggle) { - m.Settings.Categories.CategoryEnabled = !m.Settings.Categories.CategoryEnabled + m.Settings.Categories.CategoryEnabled.Value = !m.Settings.Categories.CategoryEnabled.AsBool() return m, nil } diff --git a/internal/tui/update_dashboard.go b/internal/tui/update_dashboard.go index d3df9680..d67fb706 100644 --- a/internal/tui/update_dashboard.go +++ b/internal/tui/update_dashboard.go @@ -101,7 +101,7 @@ func (m RootModel) updateDashboard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.focusedInput = 0 m.inputs[0].Focus() // Use default download dir from settings - defaultDir := m.Settings.General.DefaultDownloadDir + defaultDir := m.Settings.General.DefaultDownloadDir.AsString() if defaultDir == "" { defaultDir = "." } @@ -113,7 +113,7 @@ func (m RootModel) updateDashboard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.inputs[1].Blur() url := "" - if m.Settings.General.ClipboardMonitor { + if m.Settings.General.ClipboardMonitor.AsBool() { url = clipboard.ReadURL() } m.inputs[0].SetValue(url) @@ -203,7 +203,7 @@ func (m RootModel) updateDashboard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // Open file if key.Matches(msg, m.keys.Dashboard.OpenFile) { if d := m.GetSelectedDownload(); d != nil { - canOpen := d.done || (m.Settings.Network.SequentialDownload && !d.paused && d.Downloaded > 0) + canOpen := d.done || (m.Settings.Network.SequentialDownload.AsBool() && !d.paused && d.Downloaded > 0) if canOpen && d.Destination != "" { filePath := d.Destination if !d.done { @@ -263,7 +263,7 @@ func (m RootModel) updateDashboard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } if key.Matches(msg, m.keys.Dashboard.CategoryFilter) { - if !m.Settings.Categories.CategoryEnabled || len(m.Settings.Categories.Categories) == 0 { + if !m.Settings.Categories.CategoryEnabled.AsBool() || len(m.Settings.Categories.Categories) == 0 { if m.categoryFilter != "" { m.categoryFilter = "" m.addLogEntry(LogStyleStarted.Render("📂 Filter: All")) diff --git a/internal/tui/update_events.go b/internal/tui/update_events.go index 0780bf75..06463dc3 100644 --- a/internal/tui/update_events.go +++ b/internal/tui/update_events.go @@ -113,7 +113,7 @@ func (m RootModel) updateEvents(msg tea.Msg) (tea.Model, tea.Cmd) { duplicate := m.checkForDuplicate(msg.URL) - if duplicate != nil && m.Settings.General.WarnOnDuplicate { + if duplicate != nil && m.Settings.General.WarnOnDuplicate.AsBool() { utils.Debug("Duplicate download detected in TUI: %s", msg.URL) m.pendingURL = msg.URL m.pendingMirrors = msg.Mirrors @@ -126,7 +126,7 @@ func (m RootModel) updateEvents(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - if m.Settings != nil && m.Settings.Extension.ExtensionPrompt { + if m.Settings != nil && m.Settings.Extension.ExtensionPrompt.AsBool() { m.pendingURL = msg.URL m.pendingMirrors = msg.Mirrors m.pendingHeaders = msg.Headers diff --git a/internal/tui/update_filepicker.go b/internal/tui/update_filepicker.go index f792dd55..881a1e42 100644 --- a/internal/tui/update_filepicker.go +++ b/internal/tui/update_filepicker.go @@ -25,13 +25,13 @@ func (m RootModel) updateFilePicker(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if key.Matches(msg, m.keys.FilePicker.Cancel) { switch m.filepickerOrigin { case FilePickerOriginTheme: - m.Settings.General.ThemePath = m.filepickerOriginalPath + m.Settings.General.ThemePath.Value = m.filepickerOriginalPath m.filepickerOrigin = FilePickerOriginNone m.state = SettingsState m.resetFilepickerToDirMode() return m, nil case FilePickerOriginSettings: - m.Settings.General.DefaultDownloadDir = m.filepickerOriginalPath + m.Settings.General.DefaultDownloadDir.Value = m.filepickerOriginalPath m.filepickerOrigin = FilePickerOriginNone m.state = SettingsState m.resetFilepickerToDirMode() diff --git a/internal/tui/update_input.go b/internal/tui/update_input.go index 6c1889ff..4a2127a8 100644 --- a/internal/tui/update_input.go +++ b/internal/tui/update_input.go @@ -30,7 +30,7 @@ func (m RootModel) updateInput(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { originalPath := m.inputs[2].Value() browseDir := strings.TrimSpace(originalPath) if browseDir == "" { - browseDir = m.Settings.General.DefaultDownloadDir + browseDir = m.Settings.General.DefaultDownloadDir.AsString() } if browseDir == "" { browseDir = m.PWD diff --git a/internal/tui/update_modals.go b/internal/tui/update_modals.go index 38c7f4d6..6a07a9e4 100644 --- a/internal/tui/update_modals.go +++ b/internal/tui/update_modals.go @@ -80,7 +80,7 @@ func (m RootModel) updateBatchConfirm(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) if key.Matches(msg, m.keys.BatchConfirm.Confirm) { // Add all URLs as downloads, skipping duplicates - path := m.Settings.General.DefaultDownloadDir + path := m.Settings.General.DefaultDownloadDir.AsString() if path == "" { path = "." } @@ -170,7 +170,7 @@ func (m RootModel) updateUpdateAvailable(msg tea.KeyPressMsg) (tea.Model, tea.Cm } if key.Matches(msg, m.keys.Update.NeverRemind) { // Persist the setting and dismiss - m.Settings.General.SkipUpdateCheck = true + m.Settings.General.SkipUpdateCheck.Value = true _ = m.persistSettings() m.state = DashboardState m.UpdateInfo = nil diff --git a/internal/tui/update_settings.go b/internal/tui/update_settings.go index a7b33b8c..37a5e483 100644 --- a/internal/tui/update_settings.go +++ b/internal/tui/update_settings.go @@ -117,14 +117,14 @@ func (m RootModel) updateSettings(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { settingKey := m.getCurrentSettingKey() switch settingKey { case "default_download_dir": - originalPath := m.Settings.General.DefaultDownloadDir + originalPath := m.Settings.General.DefaultDownloadDir.AsString() browseDir := originalPath if browseDir == "" { browseDir = m.PWD } return m, m.openDirectoryPicker(FilePickerOriginSettings, originalPath, browseDir, false, true) case "theme_path": - originalPath := m.Settings.General.ThemePath + originalPath := m.Settings.General.ThemePath.AsString() browseDir := originalPath if browseDir != "" { if info, err := os.Stat(browseDir); err == nil && !info.IsDir() { @@ -181,9 +181,9 @@ func (m RootModel) updateSettings(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // Special handling for Theme cycling if settingKey == "theme" { - newTheme := (m.Settings.General.Theme + 1) % 3 - m.Settings.General.Theme = newTheme - m.ApplyTheme(newTheme, m.Settings.General.ThemePath) + newTheme := (m.Settings.General.Theme.AsInt() + 1) % 3 + m.Settings.General.Theme.Value = newTheme + m.ApplyTheme(newTheme, m.Settings.General.ThemePath.AsString()) return m, nil } @@ -250,7 +250,7 @@ func (m RootModel) updateSettings(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } if settingKey == "theme" || settingKey == "theme_path" { - m.ApplyTheme(m.Settings.General.Theme, m.Settings.General.ThemePath) + m.ApplyTheme(m.Settings.General.Theme.AsInt(), m.Settings.General.ThemePath.AsString()) } return m, nil } diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index a9ab4bd1..980a426b 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -381,8 +381,8 @@ func TestUpdate_DownloadRequestMsg(t *testing.T) { } // 1. Test Extension Prompt Enabled - m.Settings.Extension.ExtensionPrompt = true - m.Settings.General.WarnOnDuplicate = true + m.Settings.Extension.ExtensionPrompt.Value = true + m.Settings.General.WarnOnDuplicate.Value = true msg := events.DownloadRequestMsg{ URL: "http://example.com/test.zip", @@ -407,8 +407,8 @@ func TestUpdate_DownloadRequestMsg(t *testing.T) { } // 2. Test Duplicate Warning (when prompt disabled but duplicate exists) - m.Settings.Extension.ExtensionPrompt = false - m.Settings.General.WarnOnDuplicate = true + m.Settings.Extension.ExtensionPrompt.Value = false + m.Settings.General.WarnOnDuplicate.Value = true // Add existing download m.downloads = append(m.downloads, &DownloadModel{ @@ -424,8 +424,8 @@ func TestUpdate_DownloadRequestMsg(t *testing.T) { } // 3. Test No Prompt (Direct Download) - m.Settings.Extension.ExtensionPrompt = false - m.Settings.General.WarnOnDuplicate = true + m.Settings.Extension.ExtensionPrompt.Value = false + m.Settings.General.WarnOnDuplicate.Value = true m.downloads = nil // Clear downloads // Note: startDownload triggers a command (tea.Cmd), and might update state or lists. diff --git a/internal/tui/view_category.go b/internal/tui/view_category.go index 80fc0846..d8132f76 100644 --- a/internal/tui/view_category.go +++ b/internal/tui/view_category.go @@ -51,7 +51,7 @@ func (m RootModel) viewCategoryManager() string { // === TOGGLE BAR === enabledStr := "OFF" enabledColor := colors.Gray() - if m.Settings.Categories.CategoryEnabled { + if m.Settings.Categories.CategoryEnabled.AsBool() { enabledStr = "ON" enabledColor = colors.StateDownloading() } diff --git a/internal/tui/view_settings.go b/internal/tui/view_settings.go index 96dd90d0..368f2f44 100644 --- a/internal/tui/view_settings.go +++ b/internal/tui/view_settings.go @@ -584,7 +584,16 @@ func (m RootModel) getSettingsValues(category string) map[string]interface{} { if key == "" { key = field.Name } - values[key] = catVal.Field(i).Interface() + valInterface := catVal.Field(i).Interface() + if setting, ok := valInterface.(*config.Setting); ok { + if setting != nil { + values[key] = setting.Value + } else { + values[key] = nil + } + } else { + values[key] = valInterface + } } } @@ -633,92 +642,97 @@ func (m *RootModel) setSettingValue(category, key, value string) error { return nil } - // Special logic for Theme to trigger app re-rendering internally - if key == "theme" { - var theme int - valLower := strings.ToLower(value) - switch valLower { - case "system", "adaptive", "0": - theme = config.ThemeAdaptive - case "light", "1": - theme = config.ThemeLight - case "dark", "2": - theme = config.ThemeDark - default: - if v, err := strconv.Atoi(value); err == nil && v >= 0 && v <= 2 { - theme = v - } else { - return nil // Invalid - } + if setting, ok := targetField.Interface().(*config.Setting); ok { + if setting == nil { + return nil } - targetField.Set(reflect.ValueOf(theme)) - m.ApplyTheme(theme, m.Settings.General.ThemePath) - return nil - } - if key == "theme_path" { - targetField.SetString(value) - // Re-apply the current theme mode but with the brand new path - m.ApplyTheme(m.Settings.General.Theme, value) - return nil - } - // Generic Parsing and Application - switch targetField.Kind() { - case reflect.Bool: - // Typically toggled unless explicitly typed out - if value == "" { - if key == "auto_start" { - if m.ToggleServiceFunc == nil { - return fmt.Errorf("service management is not available on this platform") + // Special logic for Theme to trigger app re-rendering internally + if key == "theme" { + var theme int + valLower := strings.ToLower(value) + switch valLower { + case "system", "adaptive", "0": + theme = config.ThemeAdaptive + case "light", "1": + theme = config.ThemeLight + case "dark", "2": + theme = config.ThemeDark + default: + if v, err := strconv.Atoi(value); err == nil && v >= 0 && v <= 2 { + theme = v + } else { + return nil // Invalid } - newVal := !targetField.Bool() - if err := m.ToggleServiceFunc(newVal); err != nil { - return fmt.Errorf("failed to update service: %w", err) - } - targetField.SetBool(newVal) - return nil } - targetField.SetBool(!targetField.Bool()) - } else { - b, _ := strconv.ParseBool(value) - targetField.SetBool(b) + setting.Value = theme + m.ApplyTheme(theme, m.Settings.General.ThemePath.AsString()) + return nil } - case reflect.String: - targetField.SetString(value) - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32: - if key == "worker_buffer_size" { - if v, err := strconv.ParseFloat(value, 64); err == nil { - targetField.SetInt(int64(v * float64(config.KB))) - } - } else { - if v, err := strconv.Atoi(value); err == nil { - targetField.SetInt(int64(v)) - } + if key == "theme_path" { + setting.Value = value + // Re-apply the current theme mode but with the brand new path + m.ApplyTheme(m.Settings.General.Theme.AsInt(), value) + return nil } - case reflect.Int64: - if targetField.Type().String() == "time.Duration" { - if _, err := strconv.ParseFloat(value, 64); err == nil { - value += "s" + + // Generic Parsing and Application + switch setting.Type { + case "bool": + // Typically toggled unless explicitly typed out + if value == "" { + if key == "auto_start" { + if m.ToggleServiceFunc == nil { + return fmt.Errorf("service management is not available on this platform") + } + newVal := !setting.AsBool() + if err := m.ToggleServiceFunc(newVal); err != nil { + return fmt.Errorf("failed to update service: %w", err) + } + setting.Value = newVal + return nil + } + setting.Value = !setting.AsBool() + } else { + b, _ := strconv.ParseBool(value) + setting.Value = b } - if v, err := time.ParseDuration(value); err == nil { - targetField.Set(reflect.ValueOf(v)) + case "string", "auth_token", "link": + setting.Value = value + case "int": + if key == "worker_buffer_size" { + if v, err := strconv.ParseFloat(value, 64); err == nil { + setting.Value = int(v * float64(config.KB)) + } + } else { + if v, err := strconv.Atoi(value); err == nil { + setting.Value = v + } } - } else { + case "int64": // Handle KB/MB scaling gracefully if specified if key == "min_chunk_size" { if v, err := strconv.ParseFloat(value, 64); err == nil { - targetField.SetInt(int64(v * float64(config.MB))) + setting.Value = int64(v * float64(config.MB)) } } else { if v, err := strconv.ParseInt(value, 10, 64); err == nil { - targetField.SetInt(v) + setting.Value = v } } + case "duration": + if _, err := strconv.ParseFloat(value, 64); err == nil { + value += "s" + } + if v, err := time.ParseDuration(value); err == nil { + setting.Value = v + } + case "float64": + if v, err := strconv.ParseFloat(value, 64); err == nil { + setting.Value = v + } } - case reflect.Float32, reflect.Float64: - if v, err := strconv.ParseFloat(value, 64); err == nil { - targetField.SetFloat(v) - } + return nil } return nil @@ -909,96 +923,47 @@ func formatSettingValue(value interface{}, typ string, truncate bool) string { // resetSettingToDefault resets a specific setting to its default value func (m *RootModel) resetSettingToDefault(category, key string, defaults *config.Settings) error { - switch category { - case "General": - switch key { - case "default_download_dir": - m.Settings.General.DefaultDownloadDir = defaults.General.DefaultDownloadDir - case "warn_on_duplicate": - m.Settings.General.WarnOnDuplicate = defaults.General.WarnOnDuplicate - case "download_complete_notification": - m.Settings.General.DownloadCompleteNotification = defaults.General.DownloadCompleteNotification - case "auto_resume": - m.Settings.General.AutoResume = defaults.General.AutoResume - case "auto_start": - if m.ToggleServiceFunc != nil && m.Settings.General.AutoStart != defaults.General.AutoStart { - if err := m.ToggleServiceFunc(defaults.General.AutoStart); err != nil { - return fmt.Errorf("failed to update service: %w", err) - } + if key == "auto_start" { + if m.ToggleServiceFunc != nil && m.Settings.General.AutoStart.AsBool() != defaults.General.AutoStart.AsBool() { + if err := m.ToggleServiceFunc(defaults.General.AutoStart.AsBool()); err != nil { + return fmt.Errorf("failed to update service: %w", err) } - m.Settings.General.AutoStart = defaults.General.AutoStart - case "skip_update_check": - m.Settings.General.SkipUpdateCheck = defaults.General.SkipUpdateCheck - - case "clipboard_monitor": - m.Settings.General.ClipboardMonitor = defaults.General.ClipboardMonitor - case "allow_remote_open_actions": - m.Settings.General.AllowRemoteOpenActions = defaults.General.AllowRemoteOpenActions - case "live_speed_graph": - m.Settings.General.LiveSpeedGraph = defaults.General.LiveSpeedGraph - case "theme": - m.Settings.General.Theme = defaults.General.Theme - case "theme_path": - m.Settings.General.ThemePath = defaults.General.ThemePath - case "log_retention_count": - m.Settings.General.LogRetentionCount = defaults.General.LogRetentionCount } + } - case "Network": - // Handle Network-related keys - switch key { - case "max_connections_per_host": - m.Settings.Network.MaxConnectionsPerDownload = defaults.Network.MaxConnectionsPerDownload - - case "max_concurrent_downloads": - m.Settings.Network.MaxConcurrentDownloads = defaults.Network.MaxConcurrentDownloads - case "max_concurrent_probes": - m.Settings.Network.MaxConcurrentProbes = defaults.Network.MaxConcurrentProbes - case "user_agent": - m.Settings.Network.UserAgent = defaults.Network.UserAgent - case "proxy_url": - m.Settings.Network.ProxyURL = defaults.Network.ProxyURL - case "custom_dns": - m.Settings.Network.CustomDNS = defaults.Network.CustomDNS - case "sequential_download": - m.Settings.Network.SequentialDownload = defaults.Network.SequentialDownload - case "min_chunk_size": - m.Settings.Network.MinChunkSize = defaults.Network.MinChunkSize - case "worker_buffer_size": - m.Settings.Network.WorkerBufferSize = defaults.Network.WorkerBufferSize - case "dial_hedge_count": - m.Settings.Network.DialHedgeCount = defaults.Network.DialHedgeCount - } - case "Performance": - switch key { - case "max_task_retries": - m.Settings.Performance.MaxTaskRetries = defaults.Performance.MaxTaskRetries - case "slow_worker_threshold": - m.Settings.Performance.SlowWorkerThreshold = defaults.Performance.SlowWorkerThreshold - case "slow_worker_grace_period": - m.Settings.Performance.SlowWorkerGracePeriod = defaults.Performance.SlowWorkerGracePeriod - case "stall_timeout": - m.Settings.Performance.StallTimeout = defaults.Performance.StallTimeout - case "speed_ema_alpha": - m.Settings.Performance.SpeedEmaAlpha = defaults.Performance.SpeedEmaAlpha - } - case "Categories": - switch key { - case "category_enabled": - m.Settings.Categories.CategoryEnabled = defaults.Categories.CategoryEnabled + val1 := reflect.ValueOf(m.Settings).Elem() + val2 := reflect.ValueOf(defaults).Elem() + typ := val1.Type() + + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + label := field.Tag.Get("ui_label") + if label == "" { + label = field.Name } - case "Extension": - switch key { - case "extension_prompt": - m.Settings.Extension.ExtensionPrompt = defaults.Extension.ExtensionPrompt - case "chrome_extension_url": - m.Settings.Extension.ChromeExtensionURL = defaults.Extension.ChromeExtensionURL - case "firefox_extension_url": - m.Settings.Extension.FirefoxExtensionURL = defaults.Extension.FirefoxExtensionURL - case "instructions_url": - m.Settings.Extension.InstructionsURL = defaults.Extension.InstructionsURL - case "-": - m.Settings.Extension.AuthToken = defaults.Extension.AuthToken + if label == category { + catField1 := val1.Field(i) + catField2 := val2.Field(i) + if catField1.Kind() != reflect.Struct { + continue + } + + catTyp := catField1.Type() + for j := 0; j < catTyp.NumField(); j++ { + f := catTyp.Field(j) + fieldKey := f.Tag.Get("json") + if fieldKey == "" { + fieldKey = f.Name + } + if fieldKey == key { + s1, ok1 := catField1.Field(j).Interface().(*config.Setting) + s2, ok2 := catField2.Field(j).Interface().(*config.Setting) + if ok1 && ok2 && s1 != nil && s2 != nil { + s1.Value = s2.Value + return nil + } + } + } } } return nil From 9ab95eeac075b3e69a472c66314226a229199fb1 Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Thu, 21 May 2026 12:16:48 +0530 Subject: [PATCH 04/10] refactor: improve type resilience in settings formatting to support various numeric input types --- internal/tui/settings_unit_test.go | 75 ++++++++++++++ internal/tui/view_settings.go | 160 ++++++++++++++++++++++++++--- 2 files changed, 219 insertions(+), 16 deletions(-) diff --git a/internal/tui/settings_unit_test.go b/internal/tui/settings_unit_test.go index c2d78695..2d87a93f 100644 --- a/internal/tui/settings_unit_test.go +++ b/internal/tui/settings_unit_test.go @@ -113,3 +113,78 @@ func TestSettingsUnitConversion(t *testing.T) { }) } } + +func TestSettingsFloatResilience(t *testing.T) { + tests := []struct { + name string + key string + typ string + internalValue interface{} + expectedDisplay string + expectedEdit string + }{ + { + name: "MinChunkSize as Float64", + key: "min_chunk_size", + typ: "int64", + internalValue: float64(4 * config.MB), + expectedDisplay: "4194304", + expectedEdit: "4.0", + }, + { + name: "WorkerBufferSize as Float64", + key: "worker_buffer_size", + typ: "int", + internalValue: float64(1024 * config.KB), + expectedDisplay: "1048576", + expectedEdit: "1024", + }, + { + name: "SlowWorkerGracePeriod as Float64", + key: "slow_worker_grace_period", + typ: "duration", + internalValue: float64(10 * time.Second), + expectedDisplay: "10s", + expectedEdit: "10", + }, + { + name: "MaxTaskRetries as Float64", + key: "max_task_retries", + typ: "int", + internalValue: float64(5), + expectedDisplay: "5", + expectedEdit: "5", + }, + { + name: "Theme as Float64", + key: "theme", + typ: "int", + internalValue: float64(config.ThemeDark), + expectedDisplay: "2", // formatSettingValue returns "2" for int type + expectedEdit: "< Dark >", + }, + { + name: "WarnOnDuplicate bool as Float64", + key: "warn_on_duplicate", + typ: "bool", + internalValue: float64(1), + expectedDisplay: "True", + expectedEdit: "True", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotDisplay := formatSettingValue(tt.internalValue, tt.typ, false) + if gotDisplay != tt.expectedDisplay { + t.Errorf("%s: formatSettingValue() = %q, want %q", tt.name, gotDisplay, tt.expectedDisplay) + } + + gotEdit := formatSettingValueForEdit(tt.internalValue, tt.typ, tt.key, false) + if gotEdit != tt.expectedEdit { + t.Errorf("%s: formatSettingValueForEdit() = %q, want %q", tt.name, gotEdit, tt.expectedEdit) + } + }) + } +} + diff --git a/internal/tui/view_settings.go b/internal/tui/view_settings.go index 368f2f44..b59c7f81 100644 --- a/internal/tui/view_settings.go +++ b/internal/tui/view_settings.go @@ -829,26 +829,80 @@ func (m RootModel) getSettingUnit() string { func formatSettingValueForEdit(value interface{}, typ, key string, truncate bool) string { switch key { case "min_chunk_size": - if v, ok := value.(int64); ok { - mb := float64(v) / float64(config.MB) + var valInt64 int64 + var ok bool + switch v := value.(type) { + case int64: + valInt64 = v + ok = true + case int: + valInt64 = int64(v) + ok = true + case float64: + valInt64 = int64(v) + ok = true + } + if ok { + mb := float64(valInt64) / float64(config.MB) return fmt.Sprintf("%.1f", mb) } case "worker_buffer_size": - v := reflect.ValueOf(value) - if v.Kind() == reflect.Int { - kb := float64(v.Int()) / float64(config.KB) + var valInt int + var ok bool + switch v := value.(type) { + case int: + valInt = v + ok = true + case int64: + valInt = int(v) + ok = true + case float64: + valInt = int(v) + ok = true + } + if ok { + kb := float64(valInt) / float64(config.KB) return fmt.Sprintf("%.0f", kb) } case "slow_worker_grace_period", "stall_timeout": // Show duration as plain seconds number (e.g., "5" instead of "5s") - if d, ok := value.(time.Duration); ok { + var d time.Duration + var ok bool + switch v := value.(type) { + case time.Duration: + d = v + ok = true + case float64: + d = time.Duration(v) + ok = true + case int64: + d = time.Duration(v) + ok = true + case int: + d = time.Duration(v) + ok = true + } + if ok { return fmt.Sprintf("%.0f", d.Seconds()) } } if key == "theme" { - if v, ok := value.(int); ok { - switch v { + var valInt int + var ok bool + switch v := value.(type) { + case int: + valInt = v + ok = true + case int64: + valInt = int(v) + ok = true + case float64: + valInt = int(v) + ok = true + } + if ok { + switch valInt { case config.ThemeAdaptive: return "< System >" case config.ThemeLight: @@ -871,28 +925,102 @@ func formatSettingValue(value interface{}, typ string, truncate bool) string { switch typ { case "bool": - if b, ok := value.(bool); ok { + var b bool + var ok bool + switch val := value.(type) { + case bool: + b = val + ok = true + case float64: + b = val != 0 + ok = true + case int: + b = val != 0 + ok = true + } + if ok { if b { return "True" } return "False" } case "duration": - if d, ok := value.(time.Duration); ok { + var d time.Duration + var ok bool + switch val := value.(type) { + case time.Duration: + d = val + ok = true + case float64: + d = time.Duration(val) + ok = true + case int64: + d = time.Duration(val) + ok = true + case int: + d = time.Duration(val) + ok = true + case string: + if parsed, err := time.ParseDuration(val); err == nil { + d = parsed + ok = true + } + } + if ok { return d.String() } case "int64": - if v, ok := value.(int64); ok { - // Just display the raw number - units handled by getSettingUnit + var v int64 + var ok bool + switch val := value.(type) { + case int64: + v = val + ok = true + case int: + v = int64(val) + ok = true + case float64: + v = int64(val) + ok = true + } + if ok { return fmt.Sprintf("%d", v) } case "int": - v := reflect.ValueOf(value) - if v.Kind() == reflect.Int { - return fmt.Sprintf("%d", v.Int()) + var v int + var ok bool + switch val := value.(type) { + case int: + v = val + ok = true + case int64: + v = int(val) + ok = true + case float64: + v = int(val) + ok = true + } + if ok { + return fmt.Sprintf("%d", v) } case "float64": - if v, ok := value.(float64); ok { + var v float64 + var ok bool + switch val := value.(type) { + case float64: + v = val + ok = true + case float32: + v = float64(val) + ok = true + case int: + v = float64(val) + ok = true + case int64: + v = float64(val) + ok = true + } + if ok { return fmt.Sprintf("%.2f", v) } case "string", "link": From f8fa7536a5be1a8670fe89a6a0bdd072c48aaaa6 Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Thu, 21 May 2026 12:18:29 +0530 Subject: [PATCH 05/10] refactor: replace TUI settings conversion tests with comprehensive metadata validation and basic format tests --- internal/tui/settings_unit_test.go | 201 +++++------------------------ 1 file changed, 34 insertions(+), 167 deletions(-) diff --git a/internal/tui/settings_unit_test.go b/internal/tui/settings_unit_test.go index 2d87a93f..d150efd2 100644 --- a/internal/tui/settings_unit_test.go +++ b/internal/tui/settings_unit_test.go @@ -1,190 +1,57 @@ package tui import ( - "reflect" "testing" "time" "github.com/SurgeDM/Surge/internal/config" ) -func TestSettingsUnitConversion(t *testing.T) { - m := RootModel{ - Settings: config.DefaultSettings(), - } +func TestSettingsMetadataValidation(t *testing.T) { + metadata := config.GetSettingsMetadata() + categories := config.CategoryOrder() - tests := []struct { - name string - category string - key string - typ string - internalValue interface{} - uiInput string - expectedValue interface{} - }{ - { - name: "MinChunkSize MB Conversion", - category: "Network", - key: "min_chunk_size", - typ: "int64", - internalValue: int64(4 * config.MB), - uiInput: "4.0", - expectedValue: int64(4 * config.MB), - }, - { - name: "WorkerBufferSize KB Conversion", - category: "Network", - key: "worker_buffer_size", - typ: "int", - internalValue: 1024 * config.KB, - uiInput: "1024", - expectedValue: 1024 * config.KB, - }, - { - name: "SlowWorkerGracePeriod seconds Conversion", - category: "Performance", - key: "slow_worker_grace_period", - typ: "duration", - internalValue: 10 * time.Second, - uiInput: "10", - expectedValue: 10 * time.Second, - }, - { - name: "StallTimeout seconds Conversion", - category: "Performance", - key: "stall_timeout", - typ: "duration", - internalValue: 5 * time.Second, - uiInput: "5", - expectedValue: 5 * time.Second, - }, - { - name: "SlowWorkerThreshold float Comparison", - category: "Performance", - key: "slow_worker_threshold", - typ: "float64", - internalValue: 0.35, - uiInput: "0.35", - expectedValue: 0.35, - }, - { - name: "SpeedEmaAlpha float Comparison", - category: "Performance", - key: "speed_ema_alpha", - typ: "float64", - internalValue: 0.5, - uiInput: "0.50", - expectedValue: 0.5, - }, - { - name: "MaxTaskRetries int Comparison", - category: "Performance", - key: "max_task_retries", - typ: "int", - internalValue: 5, - uiInput: "5", - expectedValue: 5, - }, + if len(metadata) == 0 { + t.Fatal("Expected non-empty settings metadata") } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // 1. Test Internal -> UI String (formatSettingValueForEdit) - gotUI := formatSettingValueForEdit(tt.internalValue, tt.typ, tt.key, false) - // For floats like 4.0 vs 4, we normalize by parsing back - if gotUI != tt.uiInput { - t.Errorf("%s: formatSettingValueForEdit() = %q, want %q", tt.name, gotUI, tt.uiInput) - } - - // 2. Test UI String -> Internal (setSettingValue) - err := m.setSettingValue(tt.category, tt.key, tt.uiInput) - if err != nil { - t.Fatalf("%s: setSettingValue() returned error: %v", tt.name, err) - } + for _, category := range categories { + settings, ok := metadata[category] + if !ok { + t.Errorf("Category %s missing from metadata", category) + continue + } - // Read back the value using reflection similar to how the app does - values := m.getSettingsValues(tt.category) - gotInternal := values[tt.key] + if len(settings) == 0 { + t.Errorf("Category %s has no settings", category) + } - if !reflect.DeepEqual(gotInternal, tt.expectedValue) { - t.Errorf("%s: Value after setSettingValue() = %v (%T), want %v (%T)", - tt.name, gotInternal, gotInternal, tt.expectedValue, tt.expectedValue) + for _, s := range settings { + if s.Key == "" { + t.Errorf("Setting in category %s has empty Key", category) + } + if s.Label == "" { + t.Errorf("Setting %q in category %s has empty Label", s.Key, category) + } + if s.Description == "" { + t.Errorf("Setting %q in category %s has empty Description", s.Key, category) } - }) + if s.Type == "" { + t.Errorf("Setting %q in category %s has empty Type", s.Key, category) + } + } } } func TestSettingsFloatResilience(t *testing.T) { - tests := []struct { - name string - key string - typ string - internalValue interface{} - expectedDisplay string - expectedEdit string - }{ - { - name: "MinChunkSize as Float64", - key: "min_chunk_size", - typ: "int64", - internalValue: float64(4 * config.MB), - expectedDisplay: "4194304", - expectedEdit: "4.0", - }, - { - name: "WorkerBufferSize as Float64", - key: "worker_buffer_size", - typ: "int", - internalValue: float64(1024 * config.KB), - expectedDisplay: "1048576", - expectedEdit: "1024", - }, - { - name: "SlowWorkerGracePeriod as Float64", - key: "slow_worker_grace_period", - typ: "duration", - internalValue: float64(10 * time.Second), - expectedDisplay: "10s", - expectedEdit: "10", - }, - { - name: "MaxTaskRetries as Float64", - key: "max_task_retries", - typ: "int", - internalValue: float64(5), - expectedDisplay: "5", - expectedEdit: "5", - }, - { - name: "Theme as Float64", - key: "theme", - typ: "int", - internalValue: float64(config.ThemeDark), - expectedDisplay: "2", // formatSettingValue returns "2" for int type - expectedEdit: "< Dark >", - }, - { - name: "WarnOnDuplicate bool as Float64", - key: "warn_on_duplicate", - typ: "bool", - internalValue: float64(1), - expectedDisplay: "True", - expectedEdit: "True", - }, + // Verify that float64 values (e.g. from JSON deserialization) format cleanly + valInt := formatSettingValue(float64(5), "int", false) + if valInt != "5" { + t.Errorf("Expected float64(5) as int to format as \"5\", got %q", valInt) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotDisplay := formatSettingValue(tt.internalValue, tt.typ, false) - if gotDisplay != tt.expectedDisplay { - t.Errorf("%s: formatSettingValue() = %q, want %q", tt.name, gotDisplay, tt.expectedDisplay) - } - - gotEdit := formatSettingValueForEdit(tt.internalValue, tt.typ, tt.key, false) - if gotEdit != tt.expectedEdit { - t.Errorf("%s: formatSettingValueForEdit() = %q, want %q", tt.name, gotEdit, tt.expectedEdit) - } - }) + valDuration := formatSettingValue(float64(5*time.Second), "duration", false) + if valDuration != "5s" { + t.Errorf("Expected float64(5s) as duration to format as \"5s\", got %q", valDuration) } } - From 43427435c4a925d07a9026d3e8b960c84eb9e374 Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Thu, 21 May 2026 12:30:20 +0530 Subject: [PATCH 06/10] chore: replace em dashes with hyphens in comments and documentation strings --- .../ISSUE_TEMPLATE/extension_bug_report.md | 2 +- README.md | 4 +-- assets/demo.gif | Bin 1409696 -> 1409694 bytes cmd/cmd_test.go | 2 +- extension/entrypoints/background.ts | 12 ++++----- .../config/config_warning_regression_test.go | 10 ++++---- internal/config/settings.go | 4 +-- internal/download/manager.go | 2 +- internal/download/pool.go | 2 +- internal/download/pool_test.go | 14 +++++----- internal/engine/concurrent/worker.go | 2 +- internal/engine/single/downloader_test.go | 6 ++--- internal/engine/state/state.go | 4 +-- internal/engine/state/state_test.go | 10 ++++---- internal/processing/manager.go | 4 +-- internal/processing/manager_test.go | 4 +-- internal/processing/pause_resume.go | 6 ++--- internal/tui/components/box.go | 2 +- .../tui/config_warning_regression_test.go | 24 +++++++++--------- internal/tui/layout_regression_test.go | 16 ++++++------ internal/tui/update_filepicker.go | 2 +- internal/tui/view.go | 4 +-- internal/tui/view_dashboard_graph.go | 2 +- internal/utils/filename.go | 2 +- 24 files changed, 70 insertions(+), 70 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/extension_bug_report.md b/.github/ISSUE_TEMPLATE/extension_bug_report.md index 21ad0181..60a18cfc 100644 --- a/.github/ISSUE_TEMPLATE/extension_bug_report.md +++ b/.github/ISSUE_TEMPLATE/extension_bug_report.md @@ -23,7 +23,7 @@ If applicable, add screenshots to help explain your problem. **Environment Information:** - Browser: [e.g. Chrome 134 / Firefox 135] -- Extension Version: [e.g. v0.1.0 — see Support section in settings] +- Extension Version: [e.g. v0.1.0 - see Support section in settings] - OS: [e.g. Windows 11 / macOS 14 / Ubuntu 24.04] - Surge Core Version: [e.g. 1.2.0] diff --git a/README.md b/README.md index adc189de..a61a6e73 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ If Surge saves you time, consider supporting the development! Donations go direc [**☕ Buy us a coffee**](https://www.buymeacoffee.com/surge.downloader) -_Totally optional—your stars, issues, and contributions already mean the world to us! :)_ +_Totally optional-your stars, issues, and contributions already mean the world to us! :)_ --- @@ -217,7 +217,7 @@ We tested Surge against standard tools. Because of our connection optimization l | Tool | Time | Speed | Comparison | | --------------- | ---------------- | -------------------- | ------------- | -| **Surge** | **28.93s** | **35.40 MB/s** | **—** | +| **Surge** | **28.93s** | **35.40 MB/s** | **-** | | aria2c | 40.04s | 25.57 MB/s | 1.38× slower | | curl | 57.57s | 17.79 MB/s | 1.99× slower | | wget | 61.81s | 16.57 MB/s | 2.14× slower | diff --git a/assets/demo.gif b/assets/demo.gif index b0784a21aade053391255ee76baef6737c2c8275..fe234cfd2310fa67beb1ec1370185c10dde4bd44 100644 GIT binary patch delta 67 zcmZ3`8aA&rtf7Umg{g(Pg{6hHg{_6Xg`MVLLp!5xyK_4~5DNgY QAP@@yvG8{1c9GcM0Q965r2qf` delta 69 zcmbQ&8n&P{tf7Umg{g(Pg{6hHg{_6Xg`MVLLp$@MhAHii?fgJ2 T0K|eoECj^D+a23QVt)ewKkgW0 diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 16115200..92940c1b 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -237,7 +237,7 @@ func TestConnectCmd_NoServerRunning(t *testing.T) { t.Fatalf("Failed to ensure dirs: %v", err) } - // No port file exists — should return 0 + // No port file exists - should return 0 port := readActivePort() if port != 0 { t.Fatalf("Expected port 0 (no server), got %d", port) diff --git a/extension/entrypoints/background.ts b/extension/entrypoints/background.ts index c2874221..ac5e2ea6 100644 --- a/extension/entrypoints/background.ts +++ b/extension/entrypoints/background.ts @@ -451,7 +451,7 @@ function updateBadge(): void { } async function tryOpenPopup(): Promise { - try { await browser.action.openPopup(); } catch { /* ignore — requires focused window */ } + try { await browser.action.openPopup(); } catch { /* ignore - requires focused window */ } } async function isDuplicateDownload(url: string): Promise { @@ -476,7 +476,7 @@ async function handleDownloadCreated(downloadItem: { try { await browser.downloads.cancel(downloadItem.id); await browser.downloads.erase({ id: downloadItem.id } as any); - } catch { /* already completed or removed — ignore */ } + } catch { /* already completed or removed - ignore */ } const { filename, directory } = extractPathInfo(downloadItem); const duplicateDisplayName = filename || downloadItem.url.split('/').pop()?.split('?')[0] || 'Unknown file'; @@ -549,7 +549,7 @@ async function startSSEStream(): Promise { return; } - // Connected — reset retry backoff + // Connected - reset retry backoff sseRetryCount = 0; const reader = resp.body.getReader(); @@ -789,7 +789,7 @@ export default defineBackground(() => { } }); - // Header capture — Firefox doesn't support the extraHeaders permission + // Header capture - Firefox doesn't support the extraHeaders permission const isFF = (browser.runtime.getURL as (path?: string) => string)('').startsWith('moz-extension:'); const listenerOptions: Parameters[2] = ['requestHeaders']; if (!isFF) listenerOptions.push('extraHeaders'); @@ -802,7 +802,7 @@ export default defineBackground(() => { listenerOptions, ); - // Message handler — Chrome MV3 requires sendResponse + return true for async responses. + // Message handler - Chrome MV3 requires sendResponse + return true for async responses. // Returning a Promise from onMessage does NOT work without webextension-polyfill. browser.runtime.onMessage.addListener((( message: Record, @@ -818,7 +818,7 @@ export default defineBackground(() => { return true; }) as Parameters[0]); - // Health check — start SSE stream when connection is established + // Health check - start SSE stream when connection is established setInterval(async () => { const wasConnected = isConnected; await checkHealthSilent(); diff --git a/internal/config/config_warning_regression_test.go b/internal/config/config_warning_regression_test.go index b601770f..c8f4e424 100644 --- a/internal/config/config_warning_regression_test.go +++ b/internal/config/config_warning_regression_test.go @@ -34,7 +34,7 @@ func TestLoadSettings_CorruptJSON_PopulatesStartupWarning(t *testing.T) { // THE REGRESSION: StartupWarnings must NOT be empty for a corrupt file. if len(settings.StartupWarnings) == 0 { - t.Fatal("corrupt settings.json produced no StartupWarnings — config problems would be silently hidden") + t.Fatal("corrupt settings.json produced no StartupWarnings - config problems would be silently hidden") } // The warning should mention both the corruption and the reset action. @@ -70,7 +70,7 @@ func TestLoadSettings_TruncatedJSON_PopulatesStartupWarning(t *testing.T) { t.Fatal("LoadSettings returned nil for truncated JSON") } if len(settings.StartupWarnings) == 0 { - t.Fatal("truncated settings.json produced no StartupWarnings — config problems would be silently hidden") + t.Fatal("truncated settings.json produced no StartupWarnings - config problems would be silently hidden") } } @@ -96,10 +96,10 @@ func TestLoadSettings_ValidSettings_NoStartupWarnings(t *testing.T) { } // TestLoadSettings_MissingFile_NoStartupWarnings covers the first-run case where -// no settings file exists — this is expected and must not produce warnings. +// no settings file exists - this is expected and must not produce warnings. func TestLoadSettings_MissingFile_NoStartupWarnings(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - // No file created — GetSurgeDir() path doesn't exist, settings.json absent. + // No file created - GetSurgeDir() path doesn't exist, settings.json absent. settings, err := LoadSettings() if err != nil { @@ -183,7 +183,7 @@ func TestValidate_MultipleInvalidFields_AllWarningsPresent(t *testing.T) { // on already-reset settings produces zero warnings rather than accumulating. func TestValidate_ClearsOldWarningsOnRevalidation(t *testing.T) { s := DefaultSettings() - s.Network.MaxConnectionsPerDownload.Value = 999 // invalid — will be reset to default + s.Network.MaxConnectionsPerDownload.Value = 999 // invalid - will be reset to default s.Validate() firstCount := len(s.StartupWarnings) diff --git a/internal/config/settings.go b/internal/config/settings.go index 7ecd3bc4..56605037 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -258,10 +258,10 @@ func LoadSettings() (*Settings, error) { settings := DefaultSettings() // Start with defaults to fill any missing fields if err := json.Unmarshal(data, settings); err != nil { - utils.Debug("Warning: corrupt settings file %s: %v — using defaults", path, err) + utils.Debug("Warning: corrupt settings file %s: %v - using defaults", path, err) defaults := DefaultSettings() defaults.StartupWarnings = append(defaults.StartupWarnings, - fmt.Sprintf("Config: settings file is corrupt (%v) — all settings reset to defaults", err)) + fmt.Sprintf("Config: settings file is corrupt (%v) - all settings reset to defaults", err)) return defaults, nil } diff --git a/internal/download/manager.go b/internal/download/manager.go index ef8a171e..f1172fcd 100644 --- a/internal/download/manager.go +++ b/internal/download/manager.go @@ -198,7 +198,7 @@ func TUIDownload(ctx context.Context, cfg *types.DownloadConfig) error { // Determine if we should attempt a fallback to single-threaded mode. // We fallback if concurrent failed, but it wasn't a clean pause or external cancellation. if downloadErr != nil && !errors.Is(downloadErr, types.ErrPaused) && !errors.Is(downloadErr, context.Canceled) && !errors.Is(downloadErr, context.DeadlineExceeded) { - utils.Debug("Concurrent download failed: %v — falling back to single-threaded", downloadErr) + utils.Debug("Concurrent download failed: %v - falling back to single-threaded", downloadErr) useConcurrent = false // Trigger sequential block below // Reset progress state cleanly for single-stream restart from byte 0 diff --git a/internal/download/pool.go b/internal/download/pool.go index 2629bb69..d94d3c04 100644 --- a/internal/download/pool.go +++ b/internal/download/pool.go @@ -166,7 +166,7 @@ func (p *WorkerPool) GetAll() []types.DownloadConfig { } // Pause pauses a specific download by ID. Returns true if found and pause initiated -// (or already paused), false otherwise. Pure mechanical operation — no events emitted. +// (or already paused), false otherwise. Pure mechanical operation - no events emitted. func (p *WorkerPool) Pause(downloadID string) bool { p.mu.RLock() ad, exists := p.downloads[downloadID] diff --git a/internal/download/pool_test.go b/internal/download/pool_test.go index be6364e1..c9b9dcc5 100644 --- a/internal/download/pool_test.go +++ b/internal/download/pool_test.go @@ -291,7 +291,7 @@ func TestWorkerPool_Cancel_RemovesFromMap(t *testing.T) { t.Error("Expected CancelResult.Found to be true") } - // Pool must NOT emit any event — that's the caller's responsibility + // Pool must NOT emit any event - that's the caller's responsibility select { case msg := <-ch: t.Errorf("Pool should not emit events on cancel, got %T", msg) @@ -437,7 +437,7 @@ func TestWorkerPool_Cancel_QueuedDownload_RemovesFromQueueAndReturnsResult(t *te t.Fatalf("result.Filename = %q, want queued.bin", result.Filename) } - // Pool must NOT emit events — caller handles that + // Pool must NOT emit events - caller handles that select { case msg := <-ch: t.Fatalf("pool should not emit events on cancel, got %T", msg) @@ -695,7 +695,7 @@ func TestWorkerPool_ExtractPausedConfig_WhilePausing(t *testing.T) { } pool.mu.Unlock() - // Should return nil — still pausing (not safe to extract) + // Should return nil - still pausing (not safe to extract) if cfg := pool.ExtractPausedConfig("test-id"); cfg != nil { t.Fatal("Expected nil while still pausing") } @@ -824,7 +824,7 @@ func TestWorkerPool_PauseResume_Idempotency(t *testing.T) { t.Error("Expected state to be cleared after extract") } - // 4. Second ExtractPausedConfig (idempotent — already extracted) + // 4. Second ExtractPausedConfig (idempotent - already extracted) if cfg2 := pool.ExtractPausedConfig("idempotent-test"); cfg2 != nil { t.Error("Expected nil on second extract (already removed from pool)") } @@ -874,13 +874,13 @@ func TestWorkerPool_UpdateURL(t *testing.T) { pool.downloads["active-id"] = ad pool.mu.Unlock() - // 1. Try updating a running download — should fail + // 1. Try updating a running download - should fail err := pool.UpdateURL("active-id", "http://example.com/new.zip") if err == nil { t.Error("Expected error when updating URL for active download") } - // 2. Try updating a paused download — pool only updates in-memory (no DB) + // 2. Try updating a paused download - pool only updates in-memory (no DB) activeState.Paused.Store(true) ad.running.Store(false) @@ -897,7 +897,7 @@ func TestWorkerPool_UpdateURL(t *testing.T) { t.Errorf("in-memory URL not updated: got %q", gotURL) } - // 3. Try updating a queued download — should fail + // 3. Try updating a queued download - should fail pool.mu.Lock() pool.queued["queued-id"] = types.DownloadConfig{ID: "queued-id"} pool.mu.Unlock() diff --git a/internal/engine/concurrent/worker.go b/internal/engine/concurrent/worker.go index ebf926a8..51f1b224 100644 --- a/internal/engine/concurrent/worker.go +++ b/internal/engine/concurrent/worker.go @@ -530,7 +530,7 @@ func (d *ConcurrentDownloader) HedgeWork(queue *TaskQueue) bool { } queue.Push(hedgedTask) - utils.Debug("Balancer: hedged %s (range: %d-%d) — idle worker will race on fresh connection", + utils.Debug("Balancer: hedged %s (range: %d-%d) - idle worker will race on fresh connection", utils.ConvertBytesToHumanReadable(hedgedTask.Length), hedgedTask.Offset, hedgedTask.Offset+hedgedTask.Length) return true diff --git a/internal/engine/single/downloader_test.go b/internal/engine/single/downloader_test.go index e3d7bb78..1f419883 100644 --- a/internal/engine/single/downloader_test.go +++ b/internal/engine/single/downloader_test.go @@ -597,7 +597,7 @@ func TestSingleDownloader_Download_ContentIntegrity(t *testing.T) { } // ============================================================================= -// PreallocateFailure — file handle release +// PreallocateFailure - file handle release // ============================================================================= func TestSingleDownloader_PreallocateFailure_ReleasesFileHandle(t *testing.T) { @@ -641,13 +641,13 @@ func TestSingleDownloader_PreallocateFailure_ReleasesFileHandle(t *testing.T) { t.Fatal("Expected error when preallocate fails on read-only file") } if !strings.Contains(err.Error(), "preallocate") && !strings.Contains(err.Error(), "permission") { - t.Logf("Got error: %v (acceptable — file handle should still be released)", err) + t.Logf("Got error: %v (acceptable - file handle should still be released)", err) } // Verificar que o file handle foi liberado: o arquivo pode ser removido _ = os.Chmod(surgePath, 0o644) if err := os.Remove(surgePath); err != nil { - t.Errorf("Failed to remove .surge file after preallocate failure — possible file handle leak: %v", err) + t.Errorf("Failed to remove .surge file after preallocate failure - possible file handle leak: %v", err) } } diff --git a/internal/engine/state/state.go b/internal/engine/state/state.go index ba5e250a..6f6cce0e 100644 --- a/internal/engine/state/state.go +++ b/internal/engine/state/state.go @@ -895,7 +895,7 @@ func ValidateIntegrity() (int, error) { // Check if .surge file exists _, statErr := os.Stat(surgePath) if os.IsNotExist(statErr) { - // File missing — remove orphaned DB entry + // File missing - remove orphaned DB entry utils.Debug("Integrity: .surge file missing for %s, removing entry %s", e.destPath, e.id) if err := removeDownloadAndTasks(e.id); err != nil { return removed, fmt.Errorf("failed to remove orphaned entry %s: %w", e.id, err) @@ -914,7 +914,7 @@ func ValidateIntegrity() (int, error) { return removed, fmt.Errorf("failed to verify hash for %s: %w", surgePath, err) } if !matches { - // File has been tampered with — remove entry and corrupted file + // File has been tampered with - remove entry and corrupted file utils.Debug("Integrity: hash mismatch for %s (expected %s), removing", surgePath, e.fileHash) if err := retryRemove(surgePath); err != nil && !os.IsNotExist(err) { return removed, fmt.Errorf("failed to remove tampered file %s: %w", surgePath, err) diff --git a/internal/engine/state/state_test.go b/internal/engine/state/state_test.go index b4d10057..d5634042 100644 --- a/internal/engine/state/state_test.go +++ b/internal/engine/state/state_test.go @@ -732,7 +732,7 @@ func TestValidateIntegrity_MissingFile(t *testing.T) { defer CloseDB() destPath := filepath.Join(tmpDir, "missing.zip") - // Insert a paused download — but DO NOT create the .surge file + // Insert a paused download - but DO NOT create the .surge file entry := types.DownloadEntry{ ID: "integrity-missing", URL: "https://example.com/missing.zip", @@ -750,7 +750,7 @@ func TestValidateIntegrity_MissingFile(t *testing.T) { t.Fatalf("Expected entry to exist before integrity check") } - // Run integrity check — file is missing, entry should be removed + // Run integrity check - file is missing, entry should be removed removed, err := ValidateIntegrity() if err != nil { t.Fatalf("ValidateIntegrity failed: %v", err) @@ -816,7 +816,7 @@ func TestValidateIntegrity_ValidFile(t *testing.T) { t.Fatalf("Failed to set file_hash: %v", err) } - // Run integrity check — file exists with matching hash, should keep it + // Run integrity check - file exists with matching hash, should keep it removed, err := ValidateIntegrity() if err != nil { t.Fatalf("ValidateIntegrity failed: %v", err) @@ -865,7 +865,7 @@ func TestValidateIntegrity_TamperedFile(t *testing.T) { d := getDBHelper() _, _ = d.Exec("UPDATE downloads SET file_hash = ? WHERE id = ?", "0000000000000000000000000000000000000000000000000000000000000000", "integrity-tampered") - // Run integrity check — hash mismatch, entry AND file should be removed + // Run integrity check - hash mismatch, entry AND file should be removed removed, err := ValidateIntegrity() if err != nil { t.Fatalf("ValidateIntegrity failed: %v", err) @@ -891,7 +891,7 @@ func TestValidateIntegrity_CompletedIgnored(t *testing.T) { defer func() { _ = os.RemoveAll(tmpDir) }() defer CloseDB() - // Insert a completed download — should NOT be touched by integrity check + // Insert a completed download - should NOT be touched by integrity check if err := AddToMasterList(types.DownloadEntry{ ID: "integrity-completed", URL: "https://example.com/done.zip", diff --git a/internal/processing/manager.go b/internal/processing/manager.go index ce033b29..c21e3f14 100644 --- a/internal/processing/manager.go +++ b/internal/processing/manager.go @@ -246,7 +246,7 @@ func (mgr *LifecycleManager) enqueueResolved(ctx context.Context, req *DownloadR settings := mgr.GetSettings() - // Throttle concurrent probes — acquire a semaphore slot before probing. + // Throttle concurrent probes - acquire a semaphore slot before probing. // If the context is cancelled (e.g., shutdown) we abort immediately. if mgr.probeSem != nil { select { @@ -275,7 +275,7 @@ func (mgr *LifecycleManager) enqueueResolved(ctx context.Context, req *DownloadR return "", "", probeErr } - utils.Debug("Lifecycle: Probe failed: %v — enqueueing with optimistic fallback metadata\n", probeErr) + utils.Debug("Lifecycle: Probe failed: %v - enqueueing with optimistic fallback metadata\n", probeErr) // Probe failures are non-fatal for known server-side issues (403/405/500) or // network timeouts: some servers reject or intermittently fail // lightweight probe requests but still accept the actual download flow. diff --git a/internal/processing/manager_test.go b/internal/processing/manager_test.go index f82c2161..7b6e1bc4 100644 --- a/internal/processing/manager_test.go +++ b/internal/processing/manager_test.go @@ -1097,7 +1097,7 @@ func TestLifecycleManager_ProbeSemaphore_LimitsInflight(t *testing.T) { wg.Wait() close(stopPoller) - // addFunc is nil so every enqueue fails after probe — that's fine; + // addFunc is nil so every enqueue fails after probe - that's fine; // we only care that the probe phase was throttled. for _, err := range errs { if err == nil { @@ -1152,6 +1152,6 @@ func TestLifecycleManager_ProbeSemaphore_CancelledContextAbortsWait(t *testing.T } // Should abort almost instantly, not wait for the delayed slot return. if elapsed > 200*time.Millisecond { - t.Errorf("Enqueue took %v to abort — semaphore cancellation may be broken", elapsed) + t.Errorf("Enqueue took %v to abort - semaphore cancellation may be broken", elapsed) } } diff --git a/internal/processing/pause_resume.go b/internal/processing/pause_resume.go index 53a1c112..71e4c9c8 100644 --- a/internal/processing/pause_resume.go +++ b/internal/processing/pause_resume.go @@ -83,7 +83,7 @@ func hydrateConfigFromDisk(cfg *types.DownloadConfig) { // Resume resumes a paused download. // -// Hot path: download is still in pool memory (same session) — extract config directly. +// Hot path: download is still in pool memory (same session) - extract config directly. // Cold path: download was paused in a prior session, only stored in DB. func (mgr *LifecycleManager) Resume(id string) error { hooks := mgr.getEngineHooks() @@ -276,7 +276,7 @@ func (mgr *LifecycleManager) Cancel(id string) error { return nil } - // Emit removal event — event worker handles DB deletion and file cleanup. + // Emit removal event - event worker handles DB deletion and file cleanup. if hooks.PublishEvent != nil { _ = hooks.PublishEvent(events.DownloadRemovedMsg{ DownloadID: id, @@ -300,7 +300,7 @@ func (mgr *LifecycleManager) UpdateURL(id string, newURL string) error { // Pool update succeeded; persist to DB. return state.UpdateURL(id, newURL) } - // No pool connected — DB-only update is correct (no in-memory state to sync). + // No pool connected - DB-only update is correct (no in-memory state to sync). return state.UpdateURL(id, newURL) } diff --git a/internal/tui/components/box.go b/internal/tui/components/box.go index 4e15504c..7145f519 100644 --- a/internal/tui/components/box.go +++ b/internal/tui/components/box.go @@ -49,7 +49,7 @@ func RenderBtopBox(leftTitle, rightTitle string, content string, width, height i const minBorderDashes = 1 maxTitleSpace := innerWidth - minBorderDashes if maxTitleSpace <= 0 { - // No room for any title at this width — suppress both + // No room for any title at this width - suppress both leftTitle = "" leftTitleWidth = 0 rightTitle = "" diff --git a/internal/tui/config_warning_regression_test.go b/internal/tui/config_warning_regression_test.go index 07d947b2..22ca2ced 100644 --- a/internal/tui/config_warning_regression_test.go +++ b/internal/tui/config_warning_regression_test.go @@ -31,28 +31,28 @@ func newModelWithWarnings(warnings []string) RootModel { // regression test: the TUI must show config warnings in the activity log. func TestConfigWarning_StartupConfigWarningMsg_AppearsInActivityLog(t *testing.T) { m := newModelWithWarnings([]string{ - "Config: settings file is corrupt (invalid character 'n') — all settings reset to defaults", + "Config: settings file is corrupt (invalid character 'n') - all settings reset to defaults", }) - // Dispatch the message directly — same code path as Init() → cmd() → Update() + // Dispatch the message directly - same code path as Init() → cmd() → Update() updated, _ := m.Update(startupConfigWarningMsg(m.StartupConfigWarnings)) m2 := updated.(RootModel) if len(m2.logEntries) == 0 { - t.Fatal("no log entries after startupConfigWarningMsg — config warning was silently dropped") + t.Fatal("no log entries after startupConfigWarningMsg - config warning was silently dropped") } entry := strings.Join(m2.logEntries, " ") if !strings.Contains(entry, "⚠") { t.Errorf("log entry should contain warning glyph ⚠, got: %q", entry) } - // The corrupt-JSON warning text itself contains "Config:" — confirm it is present. + // The corrupt-JSON warning text itself contains "Config:" - confirm it is present. if !strings.Contains(entry, "Config:") { t.Errorf("log entry should contain 'Config:' from the warning text, got: %q", entry) } // Make sure the prefix is NOT doubled (handler must not add its own "Config:" prefix). if strings.Contains(entry, "Config: Config:") { - t.Errorf("log entry has doubled 'Config:' prefix — handler is prepending it again: %q", entry) + t.Errorf("log entry has doubled 'Config:' prefix - handler is prepending it again: %q", entry) } if !strings.Contains(entry, "corrupt") { t.Errorf("log entry should contain the original warning text, got: %q", entry) @@ -60,7 +60,7 @@ func TestConfigWarning_StartupConfigWarningMsg_AppearsInActivityLog(t *testing.T } // TestConfigWarning_MultipleWarnings_AllAppearInLog ensures each warning gets -// its own log entry — no truncation or merging. +// its own log entry - no truncation or merging. func TestConfigWarning_MultipleWarnings_AllAppearInLog(t *testing.T) { warnings := []string{ "Max connections/host reset to default (32)", @@ -108,7 +108,7 @@ func TestConfigWarning_StartupConfigWarnings_CapturedFromSettings(t *testing.T) // produce for a corrupt or invalid config). settings := config.DefaultSettings() settings.StartupWarnings = []string{ - "Config: settings file is corrupt — all settings reset to defaults", + "Config: settings file is corrupt - all settings reset to defaults", } // Build the model manually with these pre-warmed settings to simulate the @@ -164,7 +164,7 @@ func TestConfigWarning_SystemLogMsg_UsesInfoStyle(t *testing.T) { } from := "github.com/SurgeDM/Surge/internal/engine/events" - _ = from // suppress unused import — events imported via update_events.go + _ = from // suppress unused import - events imported via update_events.go // Use the events.SystemLogMsg path directly m.addLogEntry(LogStyleStarted.Render("ℹ Startup integrity check: no issues found")) @@ -193,7 +193,7 @@ func TestConfigWarning_WarningSurvivesLogTruncation(t *testing.T) { } // Now add a config warning as the 100th entry. - const configWarn = "⚠ Config: settings file is corrupt — all settings reset to defaults" + const configWarn = "⚠ Config: settings file is corrupt - all settings reset to defaults" m.addLogEntry(LogStyleError.Render(configWarn)) if len(m.logEntries) != 100 { @@ -204,13 +204,13 @@ func TestConfigWarning_WarningSurvivesLogTruncation(t *testing.T) { t.Errorf("config warning should be the newest entry (last), got: %q", last) } - // Add one more to trigger truncation — warning should be evicted (it's oldest now + // Add one more to trigger truncation - warning should be evicted (it's oldest now // only if it was first, but here it's the newest so it should survive). // Add 2 more to push over the cap: the warning is now entry 100 of 101, so truncation - // keeps entries [1..100] — warning survives. + // keeps entries [1..100] - warning survives. m.addLogEntry("post-warning filler") combined := strings.Join(m.logEntries, "\n") if !strings.Contains(combined, "corrupt") { - t.Error("config warning was evicted from the log before older filler entries — ordering is wrong") + t.Error("config warning was evicted from the log before older filler entries - ordering is wrong") } } diff --git a/internal/tui/layout_regression_test.go b/internal/tui/layout_regression_test.go index af0f3e55..56ca7ce2 100644 --- a/internal/tui/layout_regression_test.go +++ b/internal/tui/layout_regression_test.go @@ -327,7 +327,7 @@ func TestLayout_RenderBtopBoxWidthInvariant(t *testing.T) { } // RenderBtopBox always produces exactly h lines. // A box has a minimum of 3 lines (top border, content rows, bottom border), - // so for h < 3 the output will be 3 lines — that is expected behaviour. + // so for h < 3 the output will be 3 lines - that is expected behaviour. expectedLines := h if expectedLines < 3 { expectedLines = 3 @@ -480,7 +480,7 @@ func TestLayout_GetDynamicModalDimensions_BoundedByTerminal(t *testing.T) { // ───────────────────────────────────────────────────────────── // makeDownloadWithChunks creates a download model that has an active bitmap, -// mirrors, an error, and verbose fields — everything that makes the detail +// mirrors, an error, and verbose fields - everything that makes the detail // pane as tall as possible. func makeDownloadWithChunks(longURL bool) *DownloadModel { url := "https://cdn.example.com/releases/v1.2.3/file.iso" @@ -520,7 +520,7 @@ func makeDownloadWithChunks(longURL bool) *DownloadModel { } func TestLayout_RightColumnHeightNeverExceedsAvailable(t *testing.T) { - // Test across a wide range of heights — the invariant must hold for ALL of them. + // Test across a wide range of heights - the invariant must hold for ALL of them. for termH := 18; termH <= 60; termH++ { for _, termW := range []int{200, 160, 140} { t.Run(fmt.Sprintf("%dx%d", termW, termH), func(t *testing.T) { @@ -604,7 +604,7 @@ func TestLayout_ChunkMapSuppressedWhenDetailsTall(t *testing.T) { func TestLayout_DetailContentNotClippedByChunkMap(t *testing.T) { // Render the dashboard at every height from 18..50 and verify that // when the detail pane has enough room for the full content, - // all key sections are visible — none cut off by the chunk map. + // all key sections are visible - none cut off by the chunk map. for termH := 18; termH <= 50; termH++ { t.Run(fmt.Sprintf("h%d", termH), func(t *testing.T) { m := InitialRootModel(1701, "test", nil, processing.NewLifecycleManager(nil, nil), false) @@ -639,8 +639,8 @@ func TestLayout_DetailContentNotClippedByChunkMap(t *testing.T) { if contentH > maxDetailInnerH { // Even at full allocation (no chunk map), the content is - // taller than the detail pane. Clipping is expected — skip. - t.Skipf("content (%d lines) exceeds max detail inner height (%d) — terminal too short", contentH, maxDetailInnerH) + // taller than the detail pane. Clipping is expected - skip. + t.Skipf("content (%d lines) exceeds max detail inner height (%d) - terminal too short", contentH, maxDetailInnerH) } view := m.View() @@ -658,7 +658,7 @@ func TestLayout_DetailContentNotClippedByChunkMap(t *testing.T) { } for _, label := range requiredLabels { if !strings.Contains(rendered, label) { - t.Errorf("h=%d: label %q not found in rendered view — detail content was clipped (contentH=%d, maxInnerH=%d)", + t.Errorf("h=%d: label %q not found in rendered view - detail content was clipped (contentH=%d, maxInnerH=%d)", termH, label, contentH, maxDetailInnerH) } } @@ -673,7 +673,7 @@ func TestLayout_DetailContentNotClippedByChunkMap(t *testing.T) { func TestLayout_ChunkMapSpaceReclaimedForDetails(t *testing.T) { // At every height where CalculateDashboardLayout says ShowChunkMap=true, // verify that the final rendered right column still fits within - // AvailableHeight — proving that either the chunk map rendered within + // AvailableHeight - proving that either the chunk map rendered within // budget or was suppressed and its space reclaimed. for termH := 18; termH <= 60; termH++ { t.Run(fmt.Sprintf("h%d", termH), func(t *testing.T) { diff --git a/internal/tui/update_filepicker.go b/internal/tui/update_filepicker.go index 881a1e42..2e7f7927 100644 --- a/internal/tui/update_filepicker.go +++ b/internal/tui/update_filepicker.go @@ -69,7 +69,7 @@ func (m RootModel) updateFilePicker(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, cmd } - // '.' to select current directory — only in directory-picking modes. + // '.' to select current directory - only in directory-picking modes. // Skip for FilePickerOriginTheme which is file-only. if m.filepickerOrigin != FilePickerOriginTheme && key.Matches(msg, m.keys.FilePicker.UseDir) { return m.handleFilePickerSelection(m.filepicker.CurrentDirectory) diff --git a/internal/tui/view.go b/internal/tui/view.go index 55c4c0ac..0a567701 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -358,7 +358,7 @@ func (m RootModel) View() tea.View { versionBlue := colors.ThemeColor("#005cc5", "#58a6ff") versionText := lipgloss.NewStyle().Foreground(versionBlue).Render(fmt.Sprintf("v%s", m.CurrentVersion)) - // Hide help text at very narrow widths — version is more important + // Hide help text at very narrow widths - version is more important var footerContent string if layout.AvailableWidth < 60 { footerContent = versionText @@ -415,7 +415,7 @@ func (m RootModel) View() tea.View { // Measure whether the detail content actually fits in the allocated // DetailHeight. If it doesn't, the chunk map would cause details to - // be clipped — so give the chunk map's space back to details. + // be clipped - so give the chunk map's space back to details. if showActualChunkMap { detailInnerH := layout.DetailHeight - components.BorderFrameHeight if detailInnerH < 1 { diff --git a/internal/tui/view_dashboard_graph.go b/internal/tui/view_dashboard_graph.go index 9a481f41..b7b1fd1c 100644 --- a/internal/tui/view_dashboard_graph.go +++ b/internal/tui/view_dashboard_graph.go @@ -117,7 +117,7 @@ func (m *RootModel) renderGraphBox(width, height int, stats ViewStats) string { var graphWithAxis string if hideGraphStats { - // No stats box — graph gets almost full width + // No stats box - graph gets almost full width graphAreaWidth, axisWidth := GetGraphAreaDimensions(width, true) graphVisual := renderMultiLineGraph(graphData, graphAreaWidth, graphContentHeight, maxSpeed, nil) diff --git a/internal/utils/filename.go b/internal/utils/filename.go index 7479fe43..c3e38ebd 100644 --- a/internal/utils/filename.go +++ b/internal/utils/filename.go @@ -142,7 +142,7 @@ func TruncateFilename(name string) string { maxBase := MaxFilenameLength - extBytes if maxBase < 1 { - // Extension alone is too long — hard-truncate by rune so we don't split mid-char + // Extension alone is too long - hard-truncate by rune so we don't split mid-char b := []byte(name) for len(b) > MaxFilenameLength { _, size := utf8.DecodeLastRune(b) From 6a2d27bfec74fdbed931faca9fdbdadb7fed2be1 Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Thu, 21 May 2026 12:41:12 +0530 Subject: [PATCH 07/10] refactor: migrate settings access to use config.Resolve helper throughout the codebase --- cmd/http_api.go | 3 +- cmd/root.go | 2 +- cmd/root_downloads.go | 18 +- cmd/root_startup.go | 4 +- .../config/config_warning_regression_test.go | 8 +- internal/config/settings.go | 198 +++++++++++------- internal/config/settings_test.go | 132 +++++++----- internal/core/local_service.go | 6 +- internal/processing/events.go | 7 +- internal/processing/file_utils.go | 4 +- internal/processing/manager.go | 4 +- internal/processing/manager_test.go | 4 +- internal/processing/pause_resume.go | 4 +- internal/tui/helpers.go | 4 +- internal/tui/model.go | 8 +- internal/tui/process.go | 5 +- internal/tui/resume_lifecycle_test.go | 2 +- internal/tui/settings_restart_test.go | 6 +- internal/tui/update_category.go | 4 +- internal/tui/update_dashboard.go | 8 +- internal/tui/update_events.go | 4 +- internal/tui/update_input.go | 3 +- internal/tui/update_modals.go | 2 +- internal/tui/update_settings.go | 10 +- internal/tui/view_category.go | 2 +- internal/tui/view_settings.go | 12 +- 26 files changed, 270 insertions(+), 194 deletions(-) diff --git a/cmd/http_api.go b/cmd/http_api.go index be7f1024..257eb8f8 100644 --- a/cmd/http_api.go +++ b/cmd/http_api.go @@ -10,6 +10,7 @@ import ( "sort" "strings" + "github.com/SurgeDM/Surge/internal/config" "github.com/SurgeDM/Surge/internal/core" "github.com/SurgeDM/Surge/internal/engine/events" "github.com/SurgeDM/Surge/internal/utils" @@ -291,7 +292,7 @@ func ensureOpenActionRequestAllowed(r *http.Request) error { } settings := getSettings() - if settings != nil && settings.General.AllowRemoteOpenActions.AsBool() { + if settings != nil && config.Resolve[bool](settings.General.AllowRemoteOpenActions) { return nil } diff --git a/cmd/root.go b/cmd/root.go index 39d09640..7a2d235e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -441,7 +441,7 @@ var rootCmd = &cobra.Command{ PersistentPreRun: func(cmd *cobra.Command, args []string) { GlobalProgressCh = make(chan any, 100) globalSettings = getSettings() - GlobalPool = download.NewWorkerPool(GlobalProgressCh, globalSettings.Network.MaxConcurrentDownloads.AsInt()) + GlobalPool = download.NewWorkerPool(GlobalProgressCh, config.Resolve[int](globalSettings.Network.MaxConcurrentDownloads)) }, RunE: func(cmd *cobra.Command, args []string) error { if ranRemote, err := maybeRunRemoteTUI(cmd, args); err != nil { diff --git a/cmd/root_downloads.go b/cmd/root_downloads.go index b25a6ab5..c5d37cd6 100644 --- a/cmd/root_downloads.go +++ b/cmd/root_downloads.go @@ -201,7 +201,7 @@ func maybeRequireDownloadApproval(w http.ResponseWriter, service core.DownloadSe return false } - shouldPrompt := resolved.settings.Extension.ExtensionPrompt.AsBool() || (resolved.settings.General.WarnOnDuplicate.AsBool() && resolved.isDuplicate) + shouldPrompt := config.Resolve[bool](resolved.settings.Extension.ExtensionPrompt) || (config.Resolve[bool](resolved.settings.General.WarnOnDuplicate) && resolved.isDuplicate) if !shouldPrompt { return false } @@ -325,7 +325,7 @@ func processDownloads(urls []string, outputDir string, port int) int { outPath = utils.EnsureAbsPath(outPath) // CLI explicit arg means we do not auto-route when user provided an explicit output path. - isExplicit := isExplicitOutputPath(outPath, settings.General.DefaultDownloadDir.AsString()) + isExplicit := isExplicitOutputPath(outPath, config.Resolve[string](settings.General.DefaultDownloadDir)) if lifecycle == nil { err := fmt.Errorf("lifecycle manager unavailable") recordPreflightDownloadError(url, outPath, err) @@ -358,7 +358,7 @@ func resolveOutputDir(reqPath string, relativeToDefaultDir bool, defaultOutputDi } if relativeToDefaultDir && reqPath != "" { - baseDir := settings.General.DefaultDownloadDir.AsString() + baseDir := config.Resolve[string](settings.General.DefaultDownloadDir) if baseDir == "" { baseDir = defaultOutputDir } @@ -369,8 +369,8 @@ func resolveOutputDir(reqPath string, relativeToDefaultDir bool, defaultOutputDi } else if outPath == "" { if defaultOutputDir != "" { outPath = defaultOutputDir - } else if settings.General.DefaultDownloadDir.AsString() != "" { - outPath = settings.General.DefaultDownloadDir.AsString() + } else if config.Resolve[string](settings.General.DefaultDownloadDir) != "" { + outPath = config.Resolve[string](settings.General.DefaultDownloadDir) } else { outPath = "." } @@ -387,16 +387,16 @@ func mapClientWindowsPath(reqPath string, relativeToDefaultDir bool, defaultOutp baseDir := "." if relativeToDefaultDir { - if settings != nil && strings.TrimSpace(settings.General.DefaultDownloadDir.AsString()) != "" { - baseDir = settings.General.DefaultDownloadDir.AsString() + if settings != nil && strings.TrimSpace(config.Resolve[string](settings.General.DefaultDownloadDir)) != "" { + baseDir = config.Resolve[string](settings.General.DefaultDownloadDir) } else if strings.TrimSpace(defaultOutputDir) != "" { baseDir = defaultOutputDir } } else { if strings.TrimSpace(defaultOutputDir) != "" { baseDir = defaultOutputDir - } else if settings != nil && strings.TrimSpace(settings.General.DefaultDownloadDir.AsString()) != "" { - baseDir = settings.General.DefaultDownloadDir.AsString() + } else if settings != nil && strings.TrimSpace(config.Resolve[string](settings.General.DefaultDownloadDir)) != "" { + baseDir = config.Resolve[string](settings.General.DefaultDownloadDir) } } diff --git a/cmd/root_startup.go b/cmd/root_startup.go index 5ade05a0..027dd1d8 100644 --- a/cmd/root_startup.go +++ b/cmd/root_startup.go @@ -52,7 +52,7 @@ func initializeGlobalState() error { utils.ConfigureDebug(logsDir) // Clean up old logs (keeping retention-1 because a new log will be created immediately after) - retention := getSettings().General.LogRetentionCount.AsInt() + retention := config.Resolve[int](getSettings().General.LogRetentionCount) if retention > 0 { utils.CleanupLogs(retention - 1) } else { @@ -83,7 +83,7 @@ func resumePausedDownloads() { for _, entry := range pausedEntries { // If entry is explicitly queued, we should start it regardless of AutoResume setting // If entry is paused, we only start it if AutoResume is enabled - if entry.Status == "paused" && !settings.General.AutoResume.AsBool() { + if entry.Status == "paused" && !config.Resolve[bool](settings.General.AutoResume) { continue } if GlobalService == nil || entry.ID == "" { diff --git a/internal/config/config_warning_regression_test.go b/internal/config/config_warning_regression_test.go index c8f4e424..e871cac2 100644 --- a/internal/config/config_warning_regression_test.go +++ b/internal/config/config_warning_regression_test.go @@ -222,12 +222,12 @@ func TestLoadSettings_CorruptJSON_ReturnsDefaultValues(t *testing.T) { if settings == nil { t.Fatal("LoadSettings returned nil") } - if settings.Network.MaxConnectionsPerDownload.AsInt() != defaults.Network.MaxConnectionsPerDownload.AsInt() { + if Resolve[int](settings.Network.MaxConnectionsPerDownload) != Resolve[int](defaults.Network.MaxConnectionsPerDownload) { t.Errorf("MaxConnectionsPerDownload = %d, want default %d", - settings.Network.MaxConnectionsPerDownload.AsInt(), defaults.Network.MaxConnectionsPerDownload.AsInt()) + Resolve[int](settings.Network.MaxConnectionsPerDownload), Resolve[int](defaults.Network.MaxConnectionsPerDownload)) } - if settings.Performance.MaxTaskRetries.AsInt() != defaults.Performance.MaxTaskRetries.AsInt() { + if Resolve[int](settings.Performance.MaxTaskRetries) != Resolve[int](defaults.Performance.MaxTaskRetries) { t.Errorf("MaxTaskRetries = %d, want default %d", - settings.Performance.MaxTaskRetries.AsInt(), defaults.Performance.MaxTaskRetries.AsInt()) + Resolve[int](settings.Performance.MaxTaskRetries), Resolve[int](defaults.Performance.MaxTaskRetries)) } } diff --git a/internal/config/settings.go b/internal/config/settings.go index 56605037..3b9ef381 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -93,86 +93,130 @@ func (s *Setting) MarshalJSON() ([]byte, error) { return json.Marshal(s.Value) } -func (s *Setting) AsBool() bool { +// Resolve retrieves the value of a setting converted to the expected generic type T. +// This is a unified, caller-agnostic function that handles all dynamic type conversions safely. +func Resolve[T any](s *Setting) T { + var zero T if s == nil { - return false + return zero } - if b, ok := s.Value.(bool); ok { - return b + + var anyVal any = s.Value + if anyVal == nil { + anyVal = s.DefaultValue + if anyVal == nil { + return zero + } } - return false -} -func (s *Setting) AsInt() int { - if s == nil { - return 0 + // Try direct type assertion first + if val, ok := anyVal.(T); ok { + return val } - switch v := s.Value.(type) { + + // Dynamic conversions based on requested generic type T + switch any(zero).(type) { + case bool: + var b bool + switch v := anyVal.(type) { + case bool: + b = v + case int: + b = v != 0 + case int64: + b = v != 0 + case float64: + b = v != 0 + } + return any(b).(T) + case int: - return v - case float64: - return int(v) - } - return 0 -} + var i int + switch v := anyVal.(type) { + case int: + i = v + case int64: + i = int(v) + case float64: + i = int(v) + } + return any(i).(T) -func (s *Setting) AsInt64() int64 { - if s == nil { - return 0 - } - switch v := s.Value.(type) { case int64: - return v - case int: - return int64(v) - case float64: - return int64(v) - } - return 0 -} + var i int64 + switch v := anyVal.(type) { + case int64: + i = v + case int: + i = int64(v) + case float64: + i = int64(v) + } + return any(i).(T) -func (s *Setting) AsFloat64() float64 { - if s == nil { - return 0.0 - } - switch v := s.Value.(type) { case float64: - return v - case float32: - return float64(v) - case int: - return float64(v) - } - return 0.0 -} + var f float64 + switch v := anyVal.(type) { + case float64: + f = v + case float32: + f = float64(v) + case int: + f = float64(v) + case int64: + f = float64(v) + } + return any(f).(T) -func (s *Setting) AsString() string { - if s == nil { - return "" - } - if str, ok := s.Value.(string); ok { - return str + case string: + if str, ok := anyVal.(string); ok { + return any(str).(T) + } + + case time.Duration: + var d time.Duration + switch v := anyVal.(type) { + case time.Duration: + d = v + case string: + if parsed, err := time.ParseDuration(v); err == nil { + d = parsed + } + case float64: + d = time.Duration(v) + case int64: + d = time.Duration(v) + case int: + d = time.Duration(v) + } + return any(d).(T) } - return "" + + return zero } -func (s *Setting) AsDuration() time.Duration { +// Resolve returns the setting's value dynamically converted to its schema-defined target type. +// This ensures that unmarshaled types (like float64) are resolved back to their correct Go types (int, duration, etc.) +// and can be accessed safely as any. +func (s *Setting) Resolve() any { if s == nil { - return 0 + return nil } - switch v := s.Value.(type) { - case time.Duration: - return v - case string: - if d, err := time.ParseDuration(v); err == nil { - return d - } - case float64: - return time.Duration(v) - case int64: - return time.Duration(v) + switch s.Type { + case "bool": + return Resolve[bool](s) + case "int": + return Resolve[int](s) + case "int64": + return Resolve[int64](s) + case "float64": + return Resolve[float64](s) + case "string", "auth_token", "link": + return Resolve[string](s) + case "duration": + return Resolve[time.Duration](s) } - return 0 + return s.Value } func (s *Settings) initializeCategoriesList() { @@ -860,7 +904,7 @@ func (s *Settings) Validate() []string { if catPath != "" { if info, err := os.Stat(catPath); err != nil || !info.IsDir() { // Fallback to default download dir - cat.Path = s.General.DefaultDownloadDir.AsString() + cat.Path = Resolve[string](s.General.DefaultDownloadDir) s.StartupWarnings = append(s.StartupWarnings, fmt.Sprintf("Category %q path is broken; reset to default", cat.Name)) } } @@ -927,19 +971,19 @@ func SaveSettings(s *Settings) error { // ToRuntimeConfig creates the engine runtime config from validated settings. func (s *Settings) ToRuntimeConfig() *types.RuntimeConfig { return &types.RuntimeConfig{ - MaxConnectionsPerDownload: s.Network.MaxConnectionsPerDownload.AsInt(), - UserAgent: s.Network.UserAgent.AsString(), - ProxyURL: s.Network.ProxyURL.AsString(), - CustomDNS: s.Network.CustomDNS.AsString(), - SequentialDownload: s.Network.SequentialDownload.AsBool(), - MinChunkSize: s.Network.MinChunkSize.AsInt64(), - WorkerBufferSize: s.Network.WorkerBufferSize.AsInt(), - DialHedgeCount: s.Network.DialHedgeCount.AsInt(), - MaxTaskRetries: s.Performance.MaxTaskRetries.AsInt(), - SlowWorkerThreshold: s.Performance.SlowWorkerThreshold.AsFloat64(), - SlowWorkerGracePeriod: s.Performance.SlowWorkerGracePeriod.AsDuration(), - StallTimeout: s.Performance.StallTimeout.AsDuration(), - SpeedEmaAlpha: s.Performance.SpeedEmaAlpha.AsFloat64(), + MaxConnectionsPerDownload: Resolve[int](s.Network.MaxConnectionsPerDownload), + UserAgent: Resolve[string](s.Network.UserAgent), + ProxyURL: Resolve[string](s.Network.ProxyURL), + CustomDNS: Resolve[string](s.Network.CustomDNS), + SequentialDownload: Resolve[bool](s.Network.SequentialDownload), + MinChunkSize: Resolve[int64](s.Network.MinChunkSize), + WorkerBufferSize: Resolve[int](s.Network.WorkerBufferSize), + DialHedgeCount: Resolve[int](s.Network.DialHedgeCount), + MaxTaskRetries: Resolve[int](s.Performance.MaxTaskRetries), + SlowWorkerThreshold: Resolve[float64](s.Performance.SlowWorkerThreshold), + SlowWorkerGracePeriod: Resolve[time.Duration](s.Performance.SlowWorkerGracePeriod), + StallTimeout: Resolve[time.Duration](s.Performance.StallTimeout), + SpeedEmaAlpha: Resolve[float64](s.Performance.SpeedEmaAlpha), } } diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go index 4320b3bc..941ba006 100644 --- a/internal/config/settings_test.go +++ b/internal/config/settings_test.go @@ -18,82 +18,82 @@ func TestDefaultSettings(t *testing.T) { // Verify General settings t.Run("GeneralSettings", func(t *testing.T) { - if settings.General.DefaultDownloadDir.AsString() != "" { - if info, err := os.Stat(settings.General.DefaultDownloadDir.AsString()); err != nil || !info.IsDir() { - t.Errorf("DefaultDownloadDir set to invalid path: %s", settings.General.DefaultDownloadDir.AsString()) + if Resolve[string](settings.General.DefaultDownloadDir) != "" { + if info, err := os.Stat(Resolve[string](settings.General.DefaultDownloadDir)); err != nil || !info.IsDir() { + t.Errorf("DefaultDownloadDir set to invalid path: %s", Resolve[string](settings.General.DefaultDownloadDir)) } } - if !settings.General.WarnOnDuplicate.AsBool() { + if !Resolve[bool](settings.General.WarnOnDuplicate) { t.Error("WarnOnDuplicate should be true by default") } - if settings.General.AllowRemoteOpenActions.AsBool() { + if Resolve[bool](settings.General.AllowRemoteOpenActions) { t.Error("AllowRemoteOpenActions should be false by default") } - if settings.General.AutoResume.AsBool() { + if Resolve[bool](settings.General.AutoResume) { t.Error("AutoResume should be false by default") } }) // Verify Connection settings t.Run("NetworkSettings", func(t *testing.T) { - if settings.Network.MaxConnectionsPerDownload.AsInt() <= 0 { - t.Errorf("MaxConnectionsPerDownload should be positive, got: %d", settings.Network.MaxConnectionsPerDownload.AsInt()) + if Resolve[int](settings.Network.MaxConnectionsPerDownload) <= 0 { + t.Errorf("MaxConnectionsPerDownload should be positive, got: %d", Resolve[int](settings.Network.MaxConnectionsPerDownload)) } - if settings.Network.MaxConnectionsPerDownload.AsInt() > 64 { - t.Errorf("MaxConnectionsPerDownload shouldn't exceed 64, got: %d", settings.Network.MaxConnectionsPerDownload.AsInt()) + if Resolve[int](settings.Network.MaxConnectionsPerDownload) > 64 { + t.Errorf("MaxConnectionsPerDownload shouldn't exceed 64, got: %d", Resolve[int](settings.Network.MaxConnectionsPerDownload)) } - if settings.Network.SequentialDownload.AsBool() { + if Resolve[bool](settings.Network.SequentialDownload) { t.Error("SequentialDownload should be false by default") } - if settings.Network.DialHedgeCount.AsInt() != 4 { - t.Errorf("DialHedgeCount should be 4 by default, got: %d", settings.Network.DialHedgeCount.AsInt()) + if Resolve[int](settings.Network.DialHedgeCount) != 4 { + t.Errorf("DialHedgeCount should be 4 by default, got: %d", Resolve[int](settings.Network.DialHedgeCount)) } }) // Verify Chunk settings t.Run("NetworkChunkSettings", func(t *testing.T) { - if settings.Network.MinChunkSize.AsInt64() <= 0 { - t.Errorf("MinChunkSize should be positive, got: %d", settings.Network.MinChunkSize.AsInt64()) + if Resolve[int64](settings.Network.MinChunkSize) <= 0 { + t.Errorf("MinChunkSize should be positive, got: %d", Resolve[int64](settings.Network.MinChunkSize)) } - if settings.Network.WorkerBufferSize.AsInt() <= 0 { - t.Errorf("WorkerBufferSize should be positive, got: %d", settings.Network.WorkerBufferSize.AsInt()) + if Resolve[int](settings.Network.WorkerBufferSize) <= 0 { + t.Errorf("WorkerBufferSize should be positive, got: %d", Resolve[int](settings.Network.WorkerBufferSize)) } }) // Verify Performance settings t.Run("PerformanceSettings", func(t *testing.T) { - if settings.Performance.MaxTaskRetries.AsInt() < 0 { - t.Errorf("MaxTaskRetries should be non-negative, got: %d", settings.Performance.MaxTaskRetries.AsInt()) + if Resolve[int](settings.Performance.MaxTaskRetries) < 0 { + t.Errorf("MaxTaskRetries should be non-negative, got: %d", Resolve[int](settings.Performance.MaxTaskRetries)) } - if settings.Performance.SlowWorkerThreshold.AsFloat64() < 0 || settings.Performance.SlowWorkerThreshold.AsFloat64() > 1 { - t.Errorf("SlowWorkerThreshold should be between 0 and 1, got: %f", settings.Performance.SlowWorkerThreshold.AsFloat64()) + if Resolve[float64](settings.Performance.SlowWorkerThreshold) < 0 || Resolve[float64](settings.Performance.SlowWorkerThreshold) > 1 { + t.Errorf("SlowWorkerThreshold should be between 0 and 1, got: %f", Resolve[float64](settings.Performance.SlowWorkerThreshold)) } - if settings.Performance.SlowWorkerGracePeriod.AsDuration() <= 0 { - t.Errorf("SlowWorkerGracePeriod should be positive, got: %v", settings.Performance.SlowWorkerGracePeriod.AsDuration()) + if Resolve[time.Duration](settings.Performance.SlowWorkerGracePeriod) <= 0 { + t.Errorf("SlowWorkerGracePeriod should be positive, got: %v", Resolve[time.Duration](settings.Performance.SlowWorkerGracePeriod)) } - if settings.Performance.StallTimeout.AsDuration() <= 0 { - t.Errorf("StallTimeout should be positive, got: %v", settings.Performance.StallTimeout.AsDuration()) + if Resolve[time.Duration](settings.Performance.StallTimeout) <= 0 { + t.Errorf("StallTimeout should be positive, got: %v", Resolve[time.Duration](settings.Performance.StallTimeout)) } - if settings.Performance.SpeedEmaAlpha.AsFloat64() < 0 || settings.Performance.SpeedEmaAlpha.AsFloat64() > 1 { - t.Errorf("SpeedEmaAlpha should be between 0 and 1, got: %f", settings.Performance.SpeedEmaAlpha.AsFloat64()) + if Resolve[float64](settings.Performance.SpeedEmaAlpha) < 0 || Resolve[float64](settings.Performance.SpeedEmaAlpha) > 1 { + t.Errorf("SpeedEmaAlpha should be between 0 and 1, got: %f", Resolve[float64](settings.Performance.SpeedEmaAlpha)) } }) // Verify Extension settings t.Run("ExtensionSettings", func(t *testing.T) { - if !settings.Extension.ExtensionPrompt.AsBool() { + if !Resolve[bool](settings.Extension.ExtensionPrompt) { t.Error("ExtensionPrompt should be true by default") } - if settings.Extension.ChromeExtensionURL.AsString() == "" { + if Resolve[string](settings.Extension.ChromeExtensionURL) == "" { t.Error("ChromeExtensionURL should not be empty") } - if settings.Extension.FirefoxExtensionURL.AsString() == "" { + if Resolve[string](settings.Extension.FirefoxExtensionURL) == "" { t.Error("FirefoxExtensionURL should not be empty") } - if settings.Extension.InstructionsURL.AsString() == "" { + if Resolve[string](settings.Extension.InstructionsURL) == "" { t.Error("InstructionsURL should not be empty") } }) @@ -107,7 +107,7 @@ func TestDefaultSettings_Consistency(t *testing.T) { t.Error("DefaultSettings should return new instance each time") } - if s1.Network.MaxConnectionsPerDownload.AsInt() != s2.Network.MaxConnectionsPerDownload.AsInt() { + if Resolve[int](s1.Network.MaxConnectionsPerDownload) != Resolve[int](s2.Network.MaxConnectionsPerDownload) { t.Error("Default settings should be consistent") } } @@ -172,13 +172,13 @@ func TestSaveAndLoadSettings(t *testing.T) { t.Fatalf("Failed to unmarshal settings: %v", err) } - if loaded.General.DefaultDownloadDir.AsString() != original.General.DefaultDownloadDir.AsString() { - t.Errorf("DefaultDownloadDir mismatch: got %q, want %q", loaded.General.DefaultDownloadDir.AsString(), original.General.DefaultDownloadDir.AsString()) + if Resolve[string](loaded.General.DefaultDownloadDir) != Resolve[string](original.General.DefaultDownloadDir) { + t.Errorf("DefaultDownloadDir mismatch: got %q, want %q", Resolve[string](loaded.General.DefaultDownloadDir), Resolve[string](original.General.DefaultDownloadDir)) } - if loaded.General.WarnOnDuplicate.AsBool() != original.General.WarnOnDuplicate.AsBool() { + if Resolve[bool](loaded.General.WarnOnDuplicate) != Resolve[bool](original.General.WarnOnDuplicate) { t.Error("WarnOnDuplicate mismatch") } - if loaded.Network.MaxConcurrentDownloads.AsInt() != original.Network.MaxConcurrentDownloads.AsInt() { + if Resolve[int](loaded.Network.MaxConcurrentDownloads) != Resolve[int](original.Network.MaxConcurrentDownloads) { t.Error("MaxConcurrentDownloads mismatch") } } @@ -190,7 +190,7 @@ func TestLoadSettings_MissingFile(t *testing.T) { } if settings != nil { - if settings.Network.MaxConnectionsPerDownload.AsInt() <= 0 { + if Resolve[int](settings.Network.MaxConnectionsPerDownload) <= 0 { t.Error("Should return default settings with valid values") } } @@ -225,7 +225,7 @@ func TestToRuntimeConfig(t *testing.T) { t.Fatal("ToRuntimeConfig returned nil") } - if runtime.MaxConnectionsPerDownload != settings.Network.MaxConnectionsPerDownload.AsInt() { + if runtime.MaxConnectionsPerDownload != Resolve[int](settings.Network.MaxConnectionsPerDownload) { t.Error("MaxConnectionsPerDownload not correctly mapped") } } @@ -267,7 +267,7 @@ func TestSettingsJSON_Serialization(t *testing.T) { t.Fatalf("Failed to unmarshal: %v", err) } - if loaded.Network.MaxConnectionsPerDownload.AsInt() != original.Network.MaxConnectionsPerDownload.AsInt() { + if Resolve[int](loaded.Network.MaxConnectionsPerDownload) != Resolve[int](original.Network.MaxConnectionsPerDownload) { t.Error("Round-trip failed for MaxConnectionsPerDownload") } } @@ -288,10 +288,10 @@ func TestSaveSettings_RealFunction(t *testing.T) { t.Fatalf("LoadSettings failed: %v", err) } - if loaded.Network.MaxConnectionsPerDownload.AsInt() != 48 { - t.Errorf("MaxConnectionsPerDownload mismatch: got %d, want 48", loaded.Network.MaxConnectionsPerDownload.AsInt()) + if Resolve[int](loaded.Network.MaxConnectionsPerDownload) != 48 { + t.Errorf("MaxConnectionsPerDownload mismatch: got %d, want 48", Resolve[int](loaded.Network.MaxConnectionsPerDownload)) } - if !loaded.General.AutoResume.AsBool() { + if !Resolve[bool](loaded.General.AutoResume) { t.Error("AutoResume should be true") } } @@ -312,14 +312,14 @@ func TestSettings_Validate(t *testing.T) { s.Performance.SlowWorkerThreshold.Value = 0.5 }, validate: func(t *testing.T, s *Settings) { - if s.Network.MaxConnectionsPerDownload.AsInt() != 48 { - t.Errorf("Expected 48, got %d", s.Network.MaxConnectionsPerDownload.AsInt()) + if Resolve[int](s.Network.MaxConnectionsPerDownload) != 48 { + t.Errorf("Expected 48, got %d", Resolve[int](s.Network.MaxConnectionsPerDownload)) } - if s.General.LogRetentionCount.AsInt() != 10 { - t.Errorf("Expected 10, got %d", s.General.LogRetentionCount.AsInt()) + if Resolve[int](s.General.LogRetentionCount) != 10 { + t.Errorf("Expected 10, got %d", Resolve[int](s.General.LogRetentionCount)) } - if s.Performance.SlowWorkerThreshold.AsFloat64() != 0.5 { - t.Errorf("Expected 0.5, got %f", s.Performance.SlowWorkerThreshold.AsFloat64()) + if Resolve[float64](s.Performance.SlowWorkerThreshold) != 0.5 { + t.Errorf("Expected 0.5, got %f", Resolve[float64](s.Performance.SlowWorkerThreshold)) } }, }, @@ -329,8 +329,8 @@ func TestSettings_Validate(t *testing.T) { s.Network.MaxConnectionsPerDownload.Value = 999 }, validate: func(t *testing.T, s *Settings) { - if s.Network.MaxConnectionsPerDownload.AsInt() != defaults.Network.MaxConnectionsPerDownload.AsInt() { - t.Errorf("Expected default %d, got %d", defaults.Network.MaxConnectionsPerDownload.AsInt(), s.Network.MaxConnectionsPerDownload.AsInt()) + if Resolve[int](s.Network.MaxConnectionsPerDownload) != Resolve[int](defaults.Network.MaxConnectionsPerDownload) { + t.Errorf("Expected default %d, got %d", Resolve[int](defaults.Network.MaxConnectionsPerDownload), Resolve[int](s.Network.MaxConnectionsPerDownload)) } }, }, @@ -340,8 +340,8 @@ func TestSettings_Validate(t *testing.T) { s.Network.MaxConnectionsPerDownload.Value = 0 }, validate: func(t *testing.T, s *Settings) { - if s.Network.MaxConnectionsPerDownload.AsInt() != defaults.Network.MaxConnectionsPerDownload.AsInt() { - t.Errorf("Expected default %d, got %d", defaults.Network.MaxConnectionsPerDownload.AsInt(), s.Network.MaxConnectionsPerDownload.AsInt()) + if Resolve[int](s.Network.MaxConnectionsPerDownload) != Resolve[int](defaults.Network.MaxConnectionsPerDownload) { + t.Errorf("Expected default %d, got %d", Resolve[int](defaults.Network.MaxConnectionsPerDownload), Resolve[int](s.Network.MaxConnectionsPerDownload)) } }, }, @@ -356,3 +356,31 @@ func TestSettings_Validate(t *testing.T) { }) } } + +func TestResolveGeneric(t *testing.T) { + s := DefaultSettings() + + // 1. Verify int + s.Network.MaxConcurrentDownloads.Value = float64(8) + if val := Resolve[int](s.Network.MaxConcurrentDownloads); val != 8 { + t.Errorf("Resolve[int] got %d, want 8", val) + } + + // 2. Verify bool + s.General.WarnOnDuplicate.Value = float64(1) + if val := Resolve[bool](s.General.WarnOnDuplicate); !val { + t.Errorf("Resolve[bool] got false, want true") + } + + // 3. Verify string + s.Network.UserAgent.Value = "Surge/1.0" + if val := Resolve[string](s.Network.UserAgent); val != "Surge/1.0" { + t.Errorf("Resolve[string] got %q, want \"Surge/1.0\"", val) + } + + // 4. Verify duration + s.Performance.SlowWorkerGracePeriod.Value = "15s" + if val := Resolve[time.Duration](s.Performance.SlowWorkerGracePeriod); val != 15*time.Second { + t.Errorf("Resolve[time.Duration] got %v, want 15s", val) + } +} diff --git a/internal/core/local_service.go b/internal/core/local_service.go index 4239f54b..d2f3fb9f 100644 --- a/internal/core/local_service.go +++ b/internal/core/local_service.go @@ -272,7 +272,7 @@ func (s *LocalDownloadService) getSpeedEmaAlpha() float64 { return SpeedSmoothingAlpha } - alpha := settings.Performance.SpeedEmaAlpha.AsFloat64() + alpha := config.Resolve[float64](settings.Performance.SpeedEmaAlpha) if alpha <= 0 || alpha > 1 { return SpeedSmoothingAlpha } @@ -476,8 +476,8 @@ func (s *LocalDownloadService) add(url string, path string, filename string, mir outPath := path if outPath == "" { - if settings.General.DefaultDownloadDir.AsString() != "" { - outPath = settings.General.DefaultDownloadDir.AsString() + if config.Resolve[string](settings.General.DefaultDownloadDir) != "" { + outPath = config.Resolve[string](settings.General.DefaultDownloadDir) } else { outPath = "." } diff --git a/internal/processing/events.go b/internal/processing/events.go index 9fd3f012..6a8aadcd 100644 --- a/internal/processing/events.go +++ b/internal/processing/events.go @@ -7,6 +7,7 @@ import ( "syscall" "time" + "github.com/SurgeDM/Surge/internal/config" "github.com/SurgeDM/Surge/internal/engine/events" "github.com/SurgeDM/Surge/internal/engine/state" "github.com/SurgeDM/Surge/internal/engine/types" @@ -247,7 +248,7 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { if err != nil { msg = err.Error() } - if settings := mgr.GetSettings(); settings != nil && settings.General.DownloadCompleteNotification.AsBool() { + if settings := mgr.GetSettings(); settings != nil && config.Resolve[bool](settings.General.DownloadCompleteNotification) { notify(fmt.Sprintf("Download failed: %s", filename), msg) } break @@ -271,7 +272,7 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { if err := state.DeleteTasks(m.DownloadID); err != nil { utils.Debug("Lifecycle: Failed to delete completed tasks: %v", err) } - if settings := mgr.GetSettings(); settings != nil && settings.General.DownloadCompleteNotification.AsBool() { + if settings := mgr.GetSettings(); settings != nil && config.Resolve[bool](settings.General.DownloadCompleteNotification) { if filename == "" { filename = m.Filename @@ -306,7 +307,7 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { utils.Debug("Lifecycle: Failed to remove incomplete file after error: %v", err) } } - if settings := mgr.GetSettings(); settings != nil && settings.General.DownloadCompleteNotification.AsBool() { + if settings := mgr.GetSettings(); settings != nil && config.Resolve[bool](settings.General.DownloadCompleteNotification) { filename := m.Filename if filename == "" && existing != nil { diff --git a/internal/processing/file_utils.go b/internal/processing/file_utils.go index 12356b72..60df99b0 100644 --- a/internal/processing/file_utils.go +++ b/internal/processing/file_utils.go @@ -138,7 +138,7 @@ func GetUniqueFilename(dir, filename string, isNameActive func(string, string) b // GetCategoryPath applies category routing only while the caller is still using // the default destination, so explicit user paths are left untouched. func GetCategoryPath(filename, defaultDir string, settings *config.Settings) (string, error) { - if settings == nil || !settings.Categories.CategoryEnabled.AsBool() || filename == "" { + if settings == nil || !config.Resolve[bool](settings.Categories.CategoryEnabled) || filename == "" { return defaultDir, nil } @@ -176,7 +176,7 @@ func ResolveDestination(url, candidateFilename, defaultDir string, routeToCatego filename := getBaseFilename(url, candidateFilename, probe) destPath := defaultDir - if routeToCategory && settings != nil && settings.Categories.CategoryEnabled.AsBool() && filename != "" { + if routeToCategory && settings != nil && config.Resolve[bool](settings.Categories.CategoryEnabled) && filename != "" { var err error destPath, err = GetCategoryPath(filename, defaultDir, settings) if err != nil { diff --git a/internal/processing/manager.go b/internal/processing/manager.go index c21e3f14..964a1909 100644 --- a/internal/processing/manager.go +++ b/internal/processing/manager.go @@ -96,8 +96,8 @@ func NewLifecycleManager(addFunc AddDownloadFunc, addWithIDFunc AddDownloadWithI } probeCap := defaultMaxConcurrentProbes - if settings != nil && settings.Network.MaxConcurrentProbes.AsInt() > 0 { - probeCap = settings.Network.MaxConcurrentProbes.AsInt() + if settings != nil && config.Resolve[int](settings.Network.MaxConcurrentProbes) > 0 { + probeCap = config.Resolve[int](settings.Network.MaxConcurrentProbes) } sem := make(chan struct{}, probeCap) for i := 0; i < probeCap; i++ { diff --git a/internal/processing/manager_test.go b/internal/processing/manager_test.go index 7b6e1bc4..506a3138 100644 --- a/internal/processing/manager_test.go +++ b/internal/processing/manager_test.go @@ -481,7 +481,7 @@ func TestLifecycleManager_GetSettings_RefreshesFromDiskAfterTTL(t *testing.T) { settingsRefreshTTL = 0 settings := mgr.GetSettings() - if !settings.Categories.CategoryEnabled.AsBool() { + if !config.Resolve[bool](settings.Categories.CategoryEnabled) { t.Fatal("expected GetSettings to pick up saved settings after TTL expiry") } } @@ -511,7 +511,7 @@ func TestLifecycleManager_GetSettings_KeepsCachedSnapshotWhenReloadFails(t *test settingsRefreshTTL = 0 settings := mgr.GetSettings() - if settings.General.WarnOnDuplicate.AsBool() { + if config.Resolve[bool](settings.General.WarnOnDuplicate) { t.Fatal("expected GetSettings to keep the cached snapshot when disk reload fails") } } diff --git a/internal/processing/pause_resume.go b/internal/processing/pause_resume.go index 71e4c9c8..28c69ded 100644 --- a/internal/processing/pause_resume.go +++ b/internal/processing/pause_resume.go @@ -125,7 +125,7 @@ func (mgr *LifecycleManager) Resume(id string) error { settings := mgr.GetSettings() - outputPath := settings.General.DefaultDownloadDir.AsString() + outputPath := config.Resolve[string](settings.General.DefaultDownloadDir) if outputPath == "" { outputPath = "." } @@ -156,7 +156,7 @@ func (mgr *LifecycleManager) ResumeBatch(ids []string) []error { hooks := mgr.getEngineHooks() settings := mgr.GetSettings() - outputPath := settings.General.DefaultDownloadDir.AsString() + outputPath := config.Resolve[string](settings.General.DefaultDownloadDir) if outputPath == "" { outputPath = "." } diff --git a/internal/tui/helpers.go b/internal/tui/helpers.go index 21c29437..71708b50 100644 --- a/internal/tui/helpers.go +++ b/internal/tui/helpers.go @@ -75,7 +75,7 @@ func (m *RootModel) handleFilePickerSelection(path string) (tea.Model, tea.Cmd) switch m.filepickerOrigin { case FilePickerOriginTheme: m.Settings.General.ThemePath.Value = path - m.ApplyTheme(m.Settings.General.Theme.AsInt(), path) + m.ApplyTheme(config.Resolve[int](m.Settings.General.Theme), path) m.filepickerOrigin = FilePickerOriginNone m.state = SettingsState m.resetFilepickerToDirMode() @@ -114,7 +114,7 @@ func (m *RootModel) handleFilePickerGotoHome() tea.Cmd { if m.filepickerOrigin == FilePickerOriginTheme { targetDir = config.GetThemesDir() } else { - targetDir = m.Settings.General.DefaultDownloadDir.AsString() + targetDir = config.Resolve[string](m.Settings.General.DefaultDownloadDir) if targetDir == "" { homeDir, _ := os.UserHomeDir() targetDir = filepath.Join(homeDir, "Downloads") diff --git a/internal/tui/model.go b/internal/tui/model.go index 7ccacb76..eceafa1a 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -304,7 +304,7 @@ func InitialRootModel(serverPort int, currentVersion string, service core.Downlo settings.General.AutoResume.Value = false } - applyColorModeForTheme(settings.General.Theme.AsInt(), settings.General.ThemePath.AsString(), initialDarkBackground) + applyColorModeForTheme(config.Resolve[int](settings.General.Theme), config.Resolve[string](settings.General.ThemePath), initialDarkBackground) // Load paused downloads from master list (now uses global config directory) var downloads []*DownloadModel @@ -338,7 +338,7 @@ func InitialRootModel(serverPort int, currentVersion string, service core.Downlo dm.pausing = true dm.started = true case "paused": - if settings.General.AutoResume.AsBool() { + if config.Resolve[bool](settings.General.AutoResume) { dm.resuming = true dm.paused = true // Will update when resume event received } else { @@ -491,7 +491,7 @@ func (m RootModel) Init() tea.Cmd { cmds = append(cmds, m.spinner.Tick) // Trigger update check if not disabled in settings - if !m.Settings.General.SkipUpdateCheck.AsBool() { + if !config.Resolve[bool](m.Settings.General.SkipUpdateCheck) { cmds = append(cmds, checkForUpdateCmd(m.CurrentVersion)) } @@ -568,7 +568,7 @@ func (m RootModel) getFilteredDownloads() []*DownloadModel { } // Apply dashboard category filter. - if m.categoryFilter != "" && m.Settings != nil && m.Settings.Categories.CategoryEnabled.AsBool() { + if m.categoryFilter != "" && m.Settings != nil && config.Resolve[bool](m.Settings.Categories.CategoryEnabled) { if !m.matchesCategoryFilter(d) { continue } diff --git a/internal/tui/process.go b/internal/tui/process.go index 533911e1..992762fc 100644 --- a/internal/tui/process.go +++ b/internal/tui/process.go @@ -8,6 +8,7 @@ import ( "time" tea "charm.land/bubbletea/v2" + "github.com/SurgeDM/Surge/internal/config" "github.com/SurgeDM/Surge/internal/engine/events" "github.com/SurgeDM/Surge/internal/processing" "github.com/SurgeDM/Surge/internal/utils" @@ -57,7 +58,7 @@ func (m *RootModel) processProgressMsg(msg events.ProgressMsg) tea.Cmd { totalSpeed := m.calcTotalSpeed() // EMA smooth against previous graph point for visual continuity var smoothed float64 - if m.Settings != nil && m.Settings.General.LiveSpeedGraph.AsBool() { + if m.Settings != nil && config.Resolve[bool](m.Settings.General.LiveSpeedGraph) { smoothed = totalSpeed } else if len(m.SpeedHistory) > 0 { prev := m.SpeedHistory[len(m.SpeedHistory)-1] @@ -220,7 +221,7 @@ func (m RootModel) startDownload(url string, mirrors []string, headers map[strin func (m RootModel) defaultDownloadPath() string { if m.Settings != nil { - if path := strings.TrimSpace(m.Settings.General.DefaultDownloadDir.AsString()); path != "" { + if path := strings.TrimSpace(config.Resolve[string](m.Settings.General.DefaultDownloadDir)); path != "" { return path } } diff --git a/internal/tui/resume_lifecycle_test.go b/internal/tui/resume_lifecycle_test.go index 1f2663f4..38eba393 100644 --- a/internal/tui/resume_lifecycle_test.go +++ b/internal/tui/resume_lifecycle_test.go @@ -150,7 +150,7 @@ func TestResume_RespectsOriginalPath_WhenDefaultChanges(t *testing.T) { // Even if logic checks for empty/dot, filepath.Dir of absolute path is absolute path. if outputPath == "" || outputPath == "." { // This should NOT happen for absolute paths - outputPath = settings.General.DefaultDownloadDir.AsString() + outputPath = config.Resolve[string](settings.General.DefaultDownloadDir) } // Ensure outputPath resolves to DirA diff --git a/internal/tui/settings_restart_test.go b/internal/tui/settings_restart_test.go index a125e439..be96db88 100644 --- a/internal/tui/settings_restart_test.go +++ b/internal/tui/settings_restart_test.go @@ -30,20 +30,20 @@ func TestRestartRequirementDetection(t *testing.T) { } // 4. Change non-restart setting (e.g. Theme) - originalTheme := m.Settings.General.Theme.AsInt() + originalTheme := config.Resolve[int](m.Settings.General.Theme) m.Settings.General.Theme.Value = (originalTheme + 1) % 3 if m.checkRestartRequirement() { t.Error("checkRestartRequirement() should be false when only non-restart settings changed") } // 5. Change restart-required setting (e.g. MaxConcurrentDownloads) - m.Settings.Network.MaxConcurrentDownloads.Value = m.Settings.Network.MaxConcurrentDownloads.AsInt() + 1 + m.Settings.Network.MaxConcurrentDownloads.Value = config.Resolve[int](m.Settings.Network.MaxConcurrentDownloads) + 1 if !m.checkRestartRequirement() { t.Error("checkRestartRequirement() should be true when restart-required setting changed") } // 6. Reverting should make it false again - m.Settings.Network.MaxConcurrentDownloads.Value = m.Settings.Network.MaxConcurrentDownloads.AsInt() - 1 + m.Settings.Network.MaxConcurrentDownloads.Value = config.Resolve[int](m.Settings.Network.MaxConcurrentDownloads) - 1 if m.checkRestartRequirement() { t.Error("checkRestartRequirement() should be false when settings are reverted to baseline") } diff --git a/internal/tui/update_category.go b/internal/tui/update_category.go index 86cd4af4..c61556d4 100644 --- a/internal/tui/update_category.go +++ b/internal/tui/update_category.go @@ -124,7 +124,7 @@ func (m RootModel) updateCategoryManager(msg tea.KeyPressMsg) (tea.Model, tea.Cm originalPath := m.catMgrInputs[3].Value() browseDir := strings.TrimSpace(originalPath) if browseDir == "" { - browseDir = m.Settings.General.DefaultDownloadDir.AsString() + browseDir = config.Resolve[string](m.Settings.General.DefaultDownloadDir) } if browseDir == "" { browseDir = m.PWD @@ -238,7 +238,7 @@ func (m RootModel) updateCategoryManager(msg tea.KeyPressMsg) (tea.Model, tea.Cm } if key.Matches(msg, m.keys.CategoryMgr.Toggle) { - m.Settings.Categories.CategoryEnabled.Value = !m.Settings.Categories.CategoryEnabled.AsBool() + m.Settings.Categories.CategoryEnabled.Value = !config.Resolve[bool](m.Settings.Categories.CategoryEnabled) return m, nil } diff --git a/internal/tui/update_dashboard.go b/internal/tui/update_dashboard.go index d67fb706..e4747dcf 100644 --- a/internal/tui/update_dashboard.go +++ b/internal/tui/update_dashboard.go @@ -101,7 +101,7 @@ func (m RootModel) updateDashboard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.focusedInput = 0 m.inputs[0].Focus() // Use default download dir from settings - defaultDir := m.Settings.General.DefaultDownloadDir.AsString() + defaultDir := config.Resolve[string](m.Settings.General.DefaultDownloadDir) if defaultDir == "" { defaultDir = "." } @@ -113,7 +113,7 @@ func (m RootModel) updateDashboard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.inputs[1].Blur() url := "" - if m.Settings.General.ClipboardMonitor.AsBool() { + if config.Resolve[bool](m.Settings.General.ClipboardMonitor) { url = clipboard.ReadURL() } m.inputs[0].SetValue(url) @@ -203,7 +203,7 @@ func (m RootModel) updateDashboard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // Open file if key.Matches(msg, m.keys.Dashboard.OpenFile) { if d := m.GetSelectedDownload(); d != nil { - canOpen := d.done || (m.Settings.Network.SequentialDownload.AsBool() && !d.paused && d.Downloaded > 0) + canOpen := d.done || (config.Resolve[bool](m.Settings.Network.SequentialDownload) && !d.paused && d.Downloaded > 0) if canOpen && d.Destination != "" { filePath := d.Destination if !d.done { @@ -263,7 +263,7 @@ func (m RootModel) updateDashboard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } if key.Matches(msg, m.keys.Dashboard.CategoryFilter) { - if !m.Settings.Categories.CategoryEnabled.AsBool() || len(m.Settings.Categories.Categories) == 0 { + if !config.Resolve[bool](m.Settings.Categories.CategoryEnabled) || len(m.Settings.Categories.Categories) == 0 { if m.categoryFilter != "" { m.categoryFilter = "" m.addLogEntry(LogStyleStarted.Render("📂 Filter: All")) diff --git a/internal/tui/update_events.go b/internal/tui/update_events.go index 06463dc3..baf0c2cc 100644 --- a/internal/tui/update_events.go +++ b/internal/tui/update_events.go @@ -113,7 +113,7 @@ func (m RootModel) updateEvents(msg tea.Msg) (tea.Model, tea.Cmd) { duplicate := m.checkForDuplicate(msg.URL) - if duplicate != nil && m.Settings.General.WarnOnDuplicate.AsBool() { + if duplicate != nil && config.Resolve[bool](m.Settings.General.WarnOnDuplicate) { utils.Debug("Duplicate download detected in TUI: %s", msg.URL) m.pendingURL = msg.URL m.pendingMirrors = msg.Mirrors @@ -126,7 +126,7 @@ func (m RootModel) updateEvents(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - if m.Settings != nil && m.Settings.Extension.ExtensionPrompt.AsBool() { + if m.Settings != nil && config.Resolve[bool](m.Settings.Extension.ExtensionPrompt) { m.pendingURL = msg.URL m.pendingMirrors = msg.Mirrors m.pendingHeaders = msg.Headers diff --git a/internal/tui/update_input.go b/internal/tui/update_input.go index 4a2127a8..5fddbdba 100644 --- a/internal/tui/update_input.go +++ b/internal/tui/update_input.go @@ -5,6 +5,7 @@ import ( "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" + "github.com/SurgeDM/Surge/internal/config" "github.com/SurgeDM/Surge/internal/utils" ) @@ -30,7 +31,7 @@ func (m RootModel) updateInput(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { originalPath := m.inputs[2].Value() browseDir := strings.TrimSpace(originalPath) if browseDir == "" { - browseDir = m.Settings.General.DefaultDownloadDir.AsString() + browseDir = config.Resolve[string](m.Settings.General.DefaultDownloadDir) } if browseDir == "" { browseDir = m.PWD diff --git a/internal/tui/update_modals.go b/internal/tui/update_modals.go index 6a07a9e4..6c727006 100644 --- a/internal/tui/update_modals.go +++ b/internal/tui/update_modals.go @@ -80,7 +80,7 @@ func (m RootModel) updateBatchConfirm(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) if key.Matches(msg, m.keys.BatchConfirm.Confirm) { // Add all URLs as downloads, skipping duplicates - path := m.Settings.General.DefaultDownloadDir.AsString() + path := config.Resolve[string](m.Settings.General.DefaultDownloadDir) if path == "" { path = "." } diff --git a/internal/tui/update_settings.go b/internal/tui/update_settings.go index 37a5e483..b45abace 100644 --- a/internal/tui/update_settings.go +++ b/internal/tui/update_settings.go @@ -117,14 +117,14 @@ func (m RootModel) updateSettings(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { settingKey := m.getCurrentSettingKey() switch settingKey { case "default_download_dir": - originalPath := m.Settings.General.DefaultDownloadDir.AsString() + originalPath := config.Resolve[string](m.Settings.General.DefaultDownloadDir) browseDir := originalPath if browseDir == "" { browseDir = m.PWD } return m, m.openDirectoryPicker(FilePickerOriginSettings, originalPath, browseDir, false, true) case "theme_path": - originalPath := m.Settings.General.ThemePath.AsString() + originalPath := config.Resolve[string](m.Settings.General.ThemePath) browseDir := originalPath if browseDir != "" { if info, err := os.Stat(browseDir); err == nil && !info.IsDir() { @@ -181,9 +181,9 @@ func (m RootModel) updateSettings(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // Special handling for Theme cycling if settingKey == "theme" { - newTheme := (m.Settings.General.Theme.AsInt() + 1) % 3 + newTheme := (config.Resolve[int](m.Settings.General.Theme) + 1) % 3 m.Settings.General.Theme.Value = newTheme - m.ApplyTheme(newTheme, m.Settings.General.ThemePath.AsString()) + m.ApplyTheme(newTheme, config.Resolve[string](m.Settings.General.ThemePath)) return m, nil } @@ -250,7 +250,7 @@ func (m RootModel) updateSettings(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } if settingKey == "theme" || settingKey == "theme_path" { - m.ApplyTheme(m.Settings.General.Theme.AsInt(), m.Settings.General.ThemePath.AsString()) + m.ApplyTheme(config.Resolve[int](m.Settings.General.Theme), config.Resolve[string](m.Settings.General.ThemePath)) } return m, nil } diff --git a/internal/tui/view_category.go b/internal/tui/view_category.go index d8132f76..01844a78 100644 --- a/internal/tui/view_category.go +++ b/internal/tui/view_category.go @@ -51,7 +51,7 @@ func (m RootModel) viewCategoryManager() string { // === TOGGLE BAR === enabledStr := "OFF" enabledColor := colors.Gray() - if m.Settings.Categories.CategoryEnabled.AsBool() { + if config.Resolve[bool](m.Settings.Categories.CategoryEnabled) { enabledStr = "ON" enabledColor = colors.StateDownloading() } diff --git a/internal/tui/view_settings.go b/internal/tui/view_settings.go index b59c7f81..e91b221c 100644 --- a/internal/tui/view_settings.go +++ b/internal/tui/view_settings.go @@ -666,13 +666,13 @@ func (m *RootModel) setSettingValue(category, key, value string) error { } } setting.Value = theme - m.ApplyTheme(theme, m.Settings.General.ThemePath.AsString()) + m.ApplyTheme(theme, config.Resolve[string](m.Settings.General.ThemePath)) return nil } if key == "theme_path" { setting.Value = value // Re-apply the current theme mode but with the brand new path - m.ApplyTheme(m.Settings.General.Theme.AsInt(), value) + m.ApplyTheme(config.Resolve[int](m.Settings.General.Theme), value) return nil } @@ -685,14 +685,14 @@ func (m *RootModel) setSettingValue(category, key, value string) error { if m.ToggleServiceFunc == nil { return fmt.Errorf("service management is not available on this platform") } - newVal := !setting.AsBool() + newVal := !config.Resolve[bool](setting) if err := m.ToggleServiceFunc(newVal); err != nil { return fmt.Errorf("failed to update service: %w", err) } setting.Value = newVal return nil } - setting.Value = !setting.AsBool() + setting.Value = !config.Resolve[bool](setting) } else { b, _ := strconv.ParseBool(value) setting.Value = b @@ -1052,8 +1052,8 @@ func formatSettingValue(value interface{}, typ string, truncate bool) string { // resetSettingToDefault resets a specific setting to its default value func (m *RootModel) resetSettingToDefault(category, key string, defaults *config.Settings) error { if key == "auto_start" { - if m.ToggleServiceFunc != nil && m.Settings.General.AutoStart.AsBool() != defaults.General.AutoStart.AsBool() { - if err := m.ToggleServiceFunc(defaults.General.AutoStart.AsBool()); err != nil { + if m.ToggleServiceFunc != nil && config.Resolve[bool](m.Settings.General.AutoStart) != config.Resolve[bool](defaults.General.AutoStart) { + if err := m.ToggleServiceFunc(config.Resolve[bool](defaults.General.AutoStart)); err != nil { return fmt.Errorf("failed to update service: %w", err) } } From 58f3d2caed388be9213a9ccab8d167fdf53db3f2 Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Thu, 21 May 2026 12:44:00 +0530 Subject: [PATCH 08/10] feat: implement automatic type conversion and unit tests for setting values and improve settings clone error handling --- cmd/root.go | 7 ++--- internal/config/settings.go | 5 ++- internal/tui/settings_unit_test.go | 49 ++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 7a2d235e..0e7a2278 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -87,10 +87,9 @@ var ( globalEnqueueMu sync.Mutex ) -// buildActiveDownloadChecker constructs an IsNameActiveFunc callback used by the -// lifecycle manager to detect file collisions with in-flight downloads. It queries -// the provided getAll callback to check if any active download is writing to the -// target directory and filename. +// buildActiveDownloadChecker bridges the lifecycle manager and the worker pool. +// LifecycleManager has no direct reference to the pool, so we inject this closure +// at construction time to let it detect file-name collisions with in-flight downloads. func buildActiveDownloadChecker(getAll func() []types.DownloadConfig) processing.IsNameActiveFunc { if getAll == nil { return nil diff --git a/internal/config/settings.go b/internal/config/settings.go index 3b9ef381..55117dcb 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -994,9 +994,12 @@ func (s *Settings) Clone() *Settings { } data, err := json.Marshal(s) if err != nil { + utils.Debug("Warning: failed to marshal settings for Clone: %v", err) return nil } cloned := DefaultSettings() - _ = json.Unmarshal(data, cloned) + if err := json.Unmarshal(data, cloned); err != nil { + utils.Debug("Warning: failed to unmarshal settings for Clone: %v", err) + } return cloned } diff --git a/internal/tui/settings_unit_test.go b/internal/tui/settings_unit_test.go index d150efd2..f90c14bc 100644 --- a/internal/tui/settings_unit_test.go +++ b/internal/tui/settings_unit_test.go @@ -55,3 +55,52 @@ func TestSettingsFloatResilience(t *testing.T) { t.Errorf("Expected float64(5s) as duration to format as \"5s\", got %q", valDuration) } } + +func TestSetSettingValueConversions(t *testing.T) { + m := &RootModel{ + Settings: config.DefaultSettings(), + } + + // 1. Test worker_buffer_size (float -> KB-scaled int) + // Default is 32KB. Let's set it to 64 (representing 64KB, which should become 64 * 1024 = 65536) + err := m.setSettingValue("Network", "worker_buffer_size", "64") + if err != nil { + t.Fatalf("setSettingValue failed: %v", err) + } + val := config.Resolve[int](m.Settings.Network.WorkerBufferSize) + if val != 64*1024 { + t.Errorf("Expected worker_buffer_size to be %d, got %d", 64*1024, val) + } + + // 2. Test min_chunk_size (float -> MB-scaled int64) + // Default is 4MB. Let's set it to 8 (representing 8MB, which should become 8 * 1024 * 1024 = 8388608) + err = m.setSettingValue("Network", "min_chunk_size", "8") + if err != nil { + t.Fatalf("setSettingValue failed: %v", err) + } + val64 := config.Resolve[int64](m.Settings.Network.MinChunkSize) + if val64 != 8*1024*1024 { + t.Errorf("Expected min_chunk_size to be %d, got %d", 8*1024*1024, val64) + } + + // 3. Test slow_worker_grace_period / stall_timeout (number string -> time.Duration via "s" suffix injection) + // Let's set stall_timeout to "15" (which should parse as 15s) + err = m.setSettingValue("Performance", "stall_timeout", "15") + if err != nil { + t.Fatalf("setSettingValue failed: %v", err) + } + dur := config.Resolve[time.Duration](m.Settings.Performance.StallTimeout) + if dur != 15*time.Second { + t.Errorf("Expected stall_timeout to be %v, got %v", 15*time.Second, dur) + } + + // Let's set slow_worker_grace_period to "45s" (already has "s" suffix) + err = m.setSettingValue("Performance", "slow_worker_grace_period", "45s") + if err != nil { + t.Fatalf("setSettingValue failed: %v", err) + } + dur = config.Resolve[time.Duration](m.Settings.Performance.SlowWorkerGracePeriod) + if dur != 45*time.Second { + t.Errorf("Expected slow_worker_grace_period to be %v, got %v", 45*time.Second, dur) + } +} From 1e816ed762bfea8e9e8f7d7d357ce35897d55925 Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Thu, 21 May 2026 12:44:58 +0530 Subject: [PATCH 09/10] refactor: remove redundant explicit type declaration for anyVal in settings retrieval --- internal/config/settings.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/settings.go b/internal/config/settings.go index 55117dcb..ac229f8e 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -101,7 +101,7 @@ func Resolve[T any](s *Setting) T { return zero } - var anyVal any = s.Value + var anyVal = s.Value if anyVal == nil { anyVal = s.DefaultValue if anyVal == nil { From f9c0e2fc0ed2ebf45db4747aaa5a31b40d32a471 Mon Sep 17 00:00:00 2001 From: SuperCoolPencil Date: Thu, 21 May 2026 13:02:57 +0530 Subject: [PATCH 10/10] refactor: remove deprecated MaxConnectionsPerHost field from NetworkSettings --- internal/config/settings.go | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/internal/config/settings.go b/internal/config/settings.go index ac229f8e..21d1fe67 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -44,17 +44,15 @@ type GeneralSettings struct { type NetworkSettings struct { MaxConnectionsPerDownload *Setting `json:"max_connections_per_host"` - // Kept for backward compatibility - MaxConnectionsPerHost int `json:"-"` - MaxConcurrentDownloads *Setting `json:"max_concurrent_downloads"` - MaxConcurrentProbes *Setting `json:"max_concurrent_probes"` - UserAgent *Setting `json:"user_agent"` - ProxyURL *Setting `json:"proxy_url"` - CustomDNS *Setting `json:"custom_dns"` - SequentialDownload *Setting `json:"sequential_download"` - MinChunkSize *Setting `json:"min_chunk_size"` - WorkerBufferSize *Setting `json:"worker_buffer_size"` - DialHedgeCount *Setting `json:"dial_hedge_count"` + MaxConcurrentDownloads *Setting `json:"max_concurrent_downloads"` + MaxConcurrentProbes *Setting `json:"max_concurrent_probes"` + UserAgent *Setting `json:"user_agent"` + ProxyURL *Setting `json:"proxy_url"` + CustomDNS *Setting `json:"custom_dns"` + SequentialDownload *Setting `json:"sequential_download"` + MinChunkSize *Setting `json:"min_chunk_size"` + WorkerBufferSize *Setting `json:"worker_buffer_size"` + DialHedgeCount *Setting `json:"dial_hedge_count"` } type PerformanceSettings struct {