diff --git a/docs/REFACTORING.md b/docs/REFACTORING.md index bdd3a2f..ccd10ba 100644 --- a/docs/REFACTORING.md +++ b/docs/REFACTORING.md @@ -248,7 +248,7 @@ Track progress here. Mark tasks complete as they land and pass review. - [x] T1.2 — Create `src/platform/winproc`; move `configureHiddenWindow` - [x] T1.3 — Create `src/runner`; move runner logic - [x] T1.4 — Create `src/scheduler`; move scheduler -- [ ] T1.5 — Create `src/storage`; move store/paths +- [x] T1.5 — Create `src/storage`; move store/paths - [ ] T1.6 — Create `src/platform/autostart`; move autostart logic - [ ] T1.7 — Create `src/platform/desktop`; move desktop integration - [ ] T1.8 — Delete empty `src/core`; build + test both platforms diff --git a/src/core/paths.go b/src/core/paths.go index ffd4735..9a8bc95 100644 --- a/src/core/paths.go +++ b/src/core/paths.go @@ -1,54 +1 @@ package core - -import ( - "os" - "path/filepath" -) - -const ( - // The config file stays beside the executable so the portable build behaves - // predictably: moving the program folder moves its settings with it. - ConfigFileName = "gosentry.yaml" - // Older builds were named PySentry. Keep the old config name readable during - // the rename window so portable installations can start once and rewrite the - // settings to gosentry.yaml without manual file copying. - LegacyConfigFileName = "pysentry.yaml" - // Jobs are kept in a separate YAML file because the user can choose a - // different jobs directory, while application settings remain local to the - // installed/copied program. - JobsFileName = "jobs.yaml" -) - -// Paths contains both the physical program location and the resolved runtime -// storage locations. Keeping resolved paths in one struct prevents the GUI and -// scheduler from interpreting relative directories differently. -type Paths struct { - ExecutablePath string - AppDir string - ConfigPath string - JobsDir string - JobsPath string - LogsDir string - DesktopIcon string -} - -func ResolvePaths() (Paths, error) { - // os.Executable is used instead of the current working directory because GUI - // apps are often launched from Explorer, a tray shortcut, or a desktop file. - // In those cases the working directory can be surprising, but the executable - // path is stable and matches the "portable app folder" storage model. - executable, err := os.Executable() - if err != nil { - return Paths{}, err - } - - appDir := filepath.Dir(executable) - configPath := filepath.Join(appDir, ConfigFileName) - return Paths{ - ExecutablePath: executable, - AppDir: appDir, - ConfigPath: configPath, - JobsDir: appDir, - JobsPath: filepath.Join(appDir, JobsFileName), - }, nil -} diff --git a/src/core/store.go b/src/core/store.go index e8bfce3..9a8bc95 100644 --- a/src/core/store.go +++ b/src/core/store.go @@ -1,261 +1 @@ package core - -import ( - "errors" - "os" - "path/filepath" - "runtime" - "strings" - - "gitea.mixdep.ru/mix/gosentry/src/domain" - "go.yaml.in/yaml/v4" -) - -type Store struct { - Paths Paths - Config domain.Config -} - -func OpenStore() (*Store, []domain.Job, error) { - paths, err := ResolvePaths() - if err != nil { - return nil, nil, err - } - - store := &Store{Paths: paths} - config, err := loadOrCreateConfig(paths) - if err != nil { - return nil, nil, err - } - store.Config = config - store.applyConfigPaths() - // Save the config after loading so missing defaults are written back. This - // rewrites old or hand-edited files into the current clean schema without - // forcing the user to delete them manually. - if err := store.SaveConfig(); err != nil { - return nil, nil, err - } - - jobs, err := loadOrCreateJobs(store.Paths.JobsPath) - if err != nil { - return nil, nil, err - } - normalizeJobs(jobs) - // Jobs are also rewritten after normalization. That keeps jobs.yaml compact: - // only durable job definitions remain, because runtime fields are tagged - // yaml:"-" in the model. - if err := store.SaveJobs(jobs); err != nil { - return nil, nil, err - } - return store, jobs, nil -} - -func (s *Store) SaveConfig() error { - s.applyConfigPaths() - if err := os.MkdirAll(s.Paths.AppDir, 0o755); err != nil { - return err - } - return writeYAML(s.Paths.ConfigPath, s.Config) -} - -func (s *Store) SaveJobs(jobs []domain.Job) error { - if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil { - return err - } - return writeYAML(s.Paths.JobsPath, domain.JobsFile{Jobs: jobs}) -} - -func loadOrCreateConfig(paths Paths) (domain.Config, error) { - // Defaults favor a portable installation: settings and jobs begin next to the - // executable, while logs are grouped under a dedicated subdirectory. - config := domain.Config{ - JobsDir: ".", - LogsDir: "logs", - MaxLogFiles: 100, - MaxLogAgeDays: 30, - StartOnLogin: false, - KeepRunningInTray: true, - NotifyOnFailure: true, - } - - configPath := paths.ConfigPath - if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) { - legacyPath := filepath.Join(paths.AppDir, LegacyConfigFileName) - if _, legacyErr := os.Stat(legacyPath); legacyErr == nil { - // The rename from PySentry to GoSentry changed the preferred config - // filename. Read the old file once if it is still present so portable - // installs continue to start without a manual migration step. The - // caller later saves the loaded config back through SaveConfig, which - // naturally rewrites it under gosentry.yaml. - configPath = legacyPath - } else { - return config, writeYAML(paths.ConfigPath, config) - } - } - - if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) { - return config, writeYAML(paths.ConfigPath, config) - } - - data, err := os.ReadFile(configPath) - if err != nil { - return domain.Config{}, err - } - if err := yaml.Unmarshal(data, &config); err != nil { - return domain.Config{}, err - } - if strings.TrimSpace(config.JobsDir) == "" { - // Empty paths are treated as missing values rather than intentional root - // directories. This avoids accidentally writing jobs to unexpected places. - config.JobsDir = "." - } - if strings.TrimSpace(config.LogsDir) == "" { - config.LogsDir = "logs" - } - if config.MaxLogFiles <= 0 { - config.MaxLogFiles = 100 - } - if config.MaxLogAgeDays <= 0 { - config.MaxLogAgeDays = 30 - } - return config, nil -} - -func loadOrCreateJobs(path string) ([]domain.Job, error) { - if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { - // The first run creates harmless sample jobs so a new user can immediately - // see scheduled and manual execution without inventing a command. - jobs := defaultJobs() - normalizeJobs(jobs) - return jobs, writeYAML(path, domain.JobsFile{Jobs: jobs}) - } - - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var file domain.JobsFile - if err := yaml.Unmarshal(data, &file); err != nil { - return nil, err - } - return file.Jobs, nil -} - -func normalizeJobs(jobs []domain.Job) { - next := 1 - for index := range jobs { - job := &jobs[index] - if job.ID <= 0 { - // IDs are assigned only when absent. Existing IDs stay stable because - // History and future log associations use them to identify jobs. - job.ID = next - } - if job.ID >= next { - next = job.ID + 1 - } - if strings.TrimSpace(job.Name) == "" { - job.Name = "Untitled job" - } - if strings.TrimSpace(job.Schedule) == "" { - job.Schedule = "@every 1m" - } - if strings.TrimSpace(job.Command) == "" { - // An empty command would fail in a confusing way. A safe echo command - // gives the user something observable and harmless instead. - job.Command = echoCommand("GoSentry job ran") - } - job.Arguments = strings.TrimSpace(job.Arguments) - job.SuccessExitCodes = strings.TrimSpace(job.SuccessExitCodes) - if job.SuccessExitCodes == "" { - job.SuccessExitCodes = "0" - } - if job.LastRun == "" { - job.LastRun = "Never" - } - if job.Output == "" { - job.Output = "No command output captured yet." - } - if job.Enabled { - job.LastState = "Ready" - job.NextRun = "After start" - } else { - job.LastState = "Paused" - job.NextRun = "Paused" - } - // Runtime fields are reconstructed each time the app starts. Persisted run - // records live in log files, not in jobs.yaml, to keep the jobs file easy - // to review and edit by hand. - job.Logs = nil - } -} - -func resolveJobsDir(appDir string, jobsDir string) string { - return resolveConfiguredDir(appDir, jobsDir) -} - -func resolveConfiguredDir(appDir string, dir string) string { - if filepath.IsAbs(dir) { - return dir - } - // Relative paths are resolved against the executable directory, not the - // process working directory. This matches ResolvePaths and keeps shortcuts, - // Explorer launches, and terminal launches consistent. - return filepath.Clean(filepath.Join(appDir, dir)) -} - -func (s *Store) applyConfigPaths() { - s.Paths.JobsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.JobsDir) - s.Paths.JobsPath = filepath.Join(s.Paths.JobsDir, JobsFileName) - s.Paths.LogsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.LogsDir) -} - -func writeYAML(path string, value any) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - data, err := yaml.Marshal(value) - if err != nil { - return err - } - // WriteFile replaces the full file instead of patching it in place. For small - // YAML files this is simpler and prevents stale keys from older versions from - // lingering after the schema changes. - return os.WriteFile(path, data, 0o644) -} - -func defaultJobs() []domain.Job { - return []domain.Job{ - { - ID: 1, - Name: "Hello scheduler", - Folder: "Examples", - Schedule: "@every 1m", - Command: echoCommand("GoSentry test job: scheduler is alive"), - Enabled: true, - }, - { - ID: 2, - Name: "Write timestamp", - Folder: "Examples", - Schedule: "*/1 * * * *", - Command: echoCommand("GoSentry test job: timestamp command ran"), - Enabled: true, - }, - { - ID: 3, - Name: "Paused sample", - Schedule: "@every 1m", - Command: echoCommand("This paused sample should not run until enabled"), - Enabled: false, - }, - } -} - -func echoCommand(message string) string { - if runtime.GOOS == "windows" { - return "echo " + message - } - // POSIX shells need quotes for messages with spaces. Single quotes inside the - // message are escaped using the standard close-quote/backslash/reopen pattern. - return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'" -} diff --git a/src/core/store_test.go b/src/core/store_test.go index e7e39fa..9a8bc95 100644 --- a/src/core/store_test.go +++ b/src/core/store_test.go @@ -1,205 +1 @@ package core - -import ( - "path/filepath" - "strings" - "testing" - - "gitea.mixdep.ru/mix/gosentry/src/domain" - "go.yaml.in/yaml/v4" -) - -func TestJobsRoundTrip(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "jobs.yaml") - - original := []domain.Job{ - { - ID: 7, - Name: "Backup data", - Folder: "Maintenance", - Schedule: "0 2 * * *", - Command: "/usr/bin/backup", - Arguments: "--compress\n--verbose", - SuccessExitCodes: "0,1", - StartOnly: true, - Enabled: true, - }, - } - - if err := writeYAML(path, domain.JobsFile{Jobs: original}); err != nil { - t.Fatal(err) - } - - got, err := loadOrCreateJobs(path) - if err != nil { - t.Fatal(err) - } - if len(got) != 1 { - t.Fatalf("expected 1 job, got %d", len(got)) - } - - g, w := got[0], original[0] - if g.ID != w.ID { - t.Errorf("ID: got %d, want %d", g.ID, w.ID) - } - if g.Name != w.Name { - t.Errorf("Name: got %q, want %q", g.Name, w.Name) - } - if g.Folder != w.Folder { - t.Errorf("Folder: got %q, want %q", g.Folder, w.Folder) - } - if g.Schedule != w.Schedule { - t.Errorf("Schedule: got %q, want %q", g.Schedule, w.Schedule) - } - if g.Command != w.Command { - t.Errorf("Command: got %q, want %q", g.Command, w.Command) - } - if g.Arguments != w.Arguments { - t.Errorf("Arguments: got %q, want %q", g.Arguments, w.Arguments) - } - if g.SuccessExitCodes != w.SuccessExitCodes { - t.Errorf("SuccessExitCodes: got %q, want %q", g.SuccessExitCodes, w.SuccessExitCodes) - } - if g.StartOnly != w.StartOnly { - t.Errorf("StartOnly: got %v, want %v", g.StartOnly, w.StartOnly) - } - if g.Enabled != w.Enabled { - t.Errorf("Enabled: got %v, want %v", g.Enabled, w.Enabled) - } - - // Runtime fields must not survive the save→load round-trip. - if g.LastRun != "" { - t.Errorf("LastRun should be empty after load, got %q", g.LastRun) - } - if g.LastState != "" { - t.Errorf("LastState should be empty after load, got %q", g.LastState) - } - if g.Logs != nil { - t.Errorf("Logs should be nil after load, got %v", g.Logs) - } -} - -func TestConfigRoundTrip(t *testing.T) { - dir := t.TempDir() - paths := Paths{ - AppDir: dir, - ConfigPath: filepath.Join(dir, ConfigFileName), - } - - want := domain.Config{ - JobsDir: "/custom/jobs", - LogsDir: "/custom/logs", - MaxLogFiles: 50, - MaxLogAgeDays: 14, - StartOnLogin: true, - KeepRunningInTray: false, - NotifyOnFailure: false, - } - if err := writeYAML(paths.ConfigPath, want); err != nil { - t.Fatal(err) - } - - got, err := loadOrCreateConfig(paths) - if err != nil { - t.Fatal(err) - } - - if got.JobsDir != want.JobsDir { - t.Errorf("JobsDir: got %q, want %q", got.JobsDir, want.JobsDir) - } - if got.LogsDir != want.LogsDir { - t.Errorf("LogsDir: got %q, want %q", got.LogsDir, want.LogsDir) - } - if got.MaxLogFiles != want.MaxLogFiles { - t.Errorf("MaxLogFiles: got %d, want %d", got.MaxLogFiles, want.MaxLogFiles) - } - if got.MaxLogAgeDays != want.MaxLogAgeDays { - t.Errorf("MaxLogAgeDays: got %d, want %d", got.MaxLogAgeDays, want.MaxLogAgeDays) - } - if got.StartOnLogin != want.StartOnLogin { - t.Errorf("StartOnLogin: got %v, want %v", got.StartOnLogin, want.StartOnLogin) - } - if got.KeepRunningInTray != want.KeepRunningInTray { - t.Errorf("KeepRunningInTray: got %v, want %v", got.KeepRunningInTray, want.KeepRunningInTray) - } - if got.NotifyOnFailure != want.NotifyOnFailure { - t.Errorf("NotifyOnFailure: got %v, want %v", got.NotifyOnFailure, want.NotifyOnFailure) - } -} - -func TestNormalizeJobsFillsDefaults(t *testing.T) { - jobs := []domain.Job{ - {Enabled: true}, - {Enabled: false}, - {ID: 5, Name: "Kept", Schedule: "*/10 * * * *", SuccessExitCodes: "0,1", Enabled: true}, - } - - normalizeJobs(jobs) - - // Blank enabled job gets default name, schedule, command, exit codes, and runtime state. - if jobs[0].ID != 1 { - t.Errorf("first auto ID: got %d, want 1", jobs[0].ID) - } - if jobs[0].Name != "Untitled job" { - t.Errorf("default name: got %q, want 'Untitled job'", jobs[0].Name) - } - if jobs[0].Schedule != "@every 1m" { - t.Errorf("default schedule: got %q, want '@every 1m'", jobs[0].Schedule) - } - if jobs[0].SuccessExitCodes != "0" { - t.Errorf("default exit codes: got %q, want '0'", jobs[0].SuccessExitCodes) - } - if jobs[0].LastState != "Ready" { - t.Errorf("enabled job state: got %q, want 'Ready'", jobs[0].LastState) - } - if jobs[0].NextRun != "After start" { - t.Errorf("enabled job next run: got %q, want 'After start'", jobs[0].NextRun) - } - - // Disabled job is marked Paused. - if jobs[1].LastState != "Paused" { - t.Errorf("disabled job state: got %q, want 'Paused'", jobs[1].LastState) - } - if jobs[1].NextRun != "Paused" { - t.Errorf("disabled job next run: got %q, want 'Paused'", jobs[1].NextRun) - } - - // Pre-set fields survive normalization unchanged. - if jobs[2].ID != 5 { - t.Errorf("pre-set ID should be preserved: got %d, want 5", jobs[2].ID) - } - if jobs[2].SuccessExitCodes != "0,1" { - t.Errorf("pre-set exit codes should be preserved: got %q, want '0,1'", jobs[2].SuccessExitCodes) - } -} - -func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) { - jobs := []domain.Job{ - { - ID: 1, - Name: "Clean job", - Schedule: "@every 10s", - Command: echoCommand("ok"), - Enabled: true, - LastRun: "2026-06-14 12:00:00", - NextRun: "2026-06-14 12:00:10", - LastState: "OK", - Output: "stdout: ok", - Logs: []domain.RunRecord{ - {Time: "2026-06-14 12:00:00", JobName: "Clean job", Output: "stdout: ok"}, - }, - }, - } - - data, err := yaml.Marshal(domain.JobsFile{Jobs: jobs}) - if err != nil { - t.Fatal(err) - } - text := string(data) - for _, unwanted := range []string{"last_run", "next_run", "last_state", "activity", "last_output", "stdout"} { - if strings.Contains(text, unwanted) { - t.Fatalf("jobs yaml should not contain %q:\n%s", unwanted, text) - } - } -} diff --git a/src/gui/app.go b/src/gui/app.go index ca16bd5..0764ef3 100644 --- a/src/gui/app.go +++ b/src/gui/app.go @@ -17,6 +17,7 @@ import ( "gitea.mixdep.ru/mix/gosentry/src/domain" "gitea.mixdep.ru/mix/gosentry/src/runner" "gitea.mixdep.ru/mix/gosentry/src/scheduler" + "gitea.mixdep.ru/mix/gosentry/src/storage" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" @@ -162,7 +163,7 @@ func serveSingleInstance(listener net.Listener, w fyne.Window) { } func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { - store, jobs, err := core.OpenStore() + store, jobs, err := storage.OpenStore() if err != nil { return container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())), func(time.Duration, bool) {} } @@ -904,7 +905,7 @@ func logFileName(path string) string { return path } -func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObject { +func settingsView(w fyne.Window, store *storage.Store, jobs *[]job) fyne.CanvasObject { startOnLogin := widget.NewCheck("Start on login", nil) startOnLogin.SetChecked(store.Config.StartOnLogin) autostartStatus := widget.NewLabel("") diff --git a/src/scheduler/scheduler.go b/src/scheduler/scheduler.go index bf04f20..82b0607 100644 --- a/src/scheduler/scheduler.go +++ b/src/scheduler/scheduler.go @@ -7,8 +7,8 @@ import ( "sync" "time" - "gitea.mixdep.ru/mix/gosentry/src/core" "gitea.mixdep.ru/mix/gosentry/src/domain" + "gitea.mixdep.ru/mix/gosentry/src/storage" "gitea.mixdep.ru/mix/gosentry/src/runner" "github.com/robfig/cron/v3" ) @@ -20,7 +20,7 @@ var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month // this keeps the early architecture simple while storage and scheduling are // still in one desktop process. type Scheduler struct { - store *core.Store + store *storage.Store jobs *[]domain.Job onChange func(domain.RunRecord) @@ -30,7 +30,7 @@ type Scheduler struct { paused bool } -func NewScheduler(store *core.Store, jobs *[]domain.Job, onChange func(domain.RunRecord)) *Scheduler { +func NewScheduler(store *storage.Store, jobs *[]domain.Job, onChange func(domain.RunRecord)) *Scheduler { ctx, cancel := context.WithCancel(context.Background()) s := &Scheduler{ store: store, diff --git a/src/storage/paths.go b/src/storage/paths.go new file mode 100644 index 0000000..5c482e3 --- /dev/null +++ b/src/storage/paths.go @@ -0,0 +1,54 @@ +package storage + +import ( + "os" + "path/filepath" +) + +const ( + // The config file stays beside the executable so the portable build behaves + // predictably: moving the program folder moves its settings with it. + ConfigFileName = "gosentry.yaml" + // Older builds were named PySentry. Keep the old config name readable during + // the rename window so portable installations can start once and rewrite the + // settings to gosentry.yaml without manual file copying. + LegacyConfigFileName = "pysentry.yaml" + // Jobs are kept in a separate YAML file because the user can choose a + // different jobs directory, while application settings remain local to the + // installed/copied program. + JobsFileName = "jobs.yaml" +) + +// Paths contains both the physical program location and the resolved runtime +// storage locations. Keeping resolved paths in one struct prevents the GUI and +// scheduler from interpreting relative directories differently. +type Paths struct { + ExecutablePath string + AppDir string + ConfigPath string + JobsDir string + JobsPath string + LogsDir string + DesktopIcon string +} + +func ResolvePaths() (Paths, error) { + // os.Executable is used instead of the current working directory because GUI + // apps are often launched from Explorer, a tray shortcut, or a desktop file. + // In those cases the working directory can be surprising, but the executable + // path is stable and matches the "portable app folder" storage model. + executable, err := os.Executable() + if err != nil { + return Paths{}, err + } + + appDir := filepath.Dir(executable) + configPath := filepath.Join(appDir, ConfigFileName) + return Paths{ + ExecutablePath: executable, + AppDir: appDir, + ConfigPath: configPath, + JobsDir: appDir, + JobsPath: filepath.Join(appDir, JobsFileName), + }, nil +} diff --git a/src/storage/store.go b/src/storage/store.go new file mode 100644 index 0000000..c5cfecc --- /dev/null +++ b/src/storage/store.go @@ -0,0 +1,261 @@ +package storage + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + + "gitea.mixdep.ru/mix/gosentry/src/domain" + "go.yaml.in/yaml/v4" +) + +type Store struct { + Paths Paths + Config domain.Config +} + +func OpenStore() (*Store, []domain.Job, error) { + paths, err := ResolvePaths() + if err != nil { + return nil, nil, err + } + + store := &Store{Paths: paths} + config, err := loadOrCreateConfig(paths) + if err != nil { + return nil, nil, err + } + store.Config = config + store.applyConfigPaths() + // Save the config after loading so missing defaults are written back. This + // rewrites old or hand-edited files into the current clean schema without + // forcing the user to delete them manually. + if err := store.SaveConfig(); err != nil { + return nil, nil, err + } + + jobs, err := loadOrCreateJobs(store.Paths.JobsPath) + if err != nil { + return nil, nil, err + } + normalizeJobs(jobs) + // Jobs are also rewritten after normalization. That keeps jobs.yaml compact: + // only durable job definitions remain, because runtime fields are tagged + // yaml:"-" in the model. + if err := store.SaveJobs(jobs); err != nil { + return nil, nil, err + } + return store, jobs, nil +} + +func (s *Store) SaveConfig() error { + s.applyConfigPaths() + if err := os.MkdirAll(s.Paths.AppDir, 0o755); err != nil { + return err + } + return writeYAML(s.Paths.ConfigPath, s.Config) +} + +func (s *Store) SaveJobs(jobs []domain.Job) error { + if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil { + return err + } + return writeYAML(s.Paths.JobsPath, domain.JobsFile{Jobs: jobs}) +} + +func loadOrCreateConfig(paths Paths) (domain.Config, error) { + // Defaults favor a portable installation: settings and jobs begin next to the + // executable, while logs are grouped under a dedicated subdirectory. + config := domain.Config{ + JobsDir: ".", + LogsDir: "logs", + MaxLogFiles: 100, + MaxLogAgeDays: 30, + StartOnLogin: false, + KeepRunningInTray: true, + NotifyOnFailure: true, + } + + configPath := paths.ConfigPath + if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) { + legacyPath := filepath.Join(paths.AppDir, LegacyConfigFileName) + if _, legacyErr := os.Stat(legacyPath); legacyErr == nil { + // The rename from PySentry to GoSentry changed the preferred config + // filename. Read the old file once if it is still present so portable + // installs continue to start without a manual migration step. The + // caller later saves the loaded config back through SaveConfig, which + // naturally rewrites it under gosentry.yaml. + configPath = legacyPath + } else { + return config, writeYAML(paths.ConfigPath, config) + } + } + + if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) { + return config, writeYAML(paths.ConfigPath, config) + } + + data, err := os.ReadFile(configPath) + if err != nil { + return domain.Config{}, err + } + if err := yaml.Unmarshal(data, &config); err != nil { + return domain.Config{}, err + } + if strings.TrimSpace(config.JobsDir) == "" { + // Empty paths are treated as missing values rather than intentional root + // directories. This avoids accidentally writing jobs to unexpected places. + config.JobsDir = "." + } + if strings.TrimSpace(config.LogsDir) == "" { + config.LogsDir = "logs" + } + if config.MaxLogFiles <= 0 { + config.MaxLogFiles = 100 + } + if config.MaxLogAgeDays <= 0 { + config.MaxLogAgeDays = 30 + } + return config, nil +} + +func loadOrCreateJobs(path string) ([]domain.Job, error) { + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + // The first run creates harmless sample jobs so a new user can immediately + // see scheduled and manual execution without inventing a command. + jobs := defaultJobs() + normalizeJobs(jobs) + return jobs, writeYAML(path, domain.JobsFile{Jobs: jobs}) + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var file domain.JobsFile + if err := yaml.Unmarshal(data, &file); err != nil { + return nil, err + } + return file.Jobs, nil +} + +func normalizeJobs(jobs []domain.Job) { + next := 1 + for index := range jobs { + job := &jobs[index] + if job.ID <= 0 { + // IDs are assigned only when absent. Existing IDs stay stable because + // History and future log associations use them to identify jobs. + job.ID = next + } + if job.ID >= next { + next = job.ID + 1 + } + if strings.TrimSpace(job.Name) == "" { + job.Name = "Untitled job" + } + if strings.TrimSpace(job.Schedule) == "" { + job.Schedule = "@every 1m" + } + if strings.TrimSpace(job.Command) == "" { + // An empty command would fail in a confusing way. A safe echo command + // gives the user something observable and harmless instead. + job.Command = echoCommand("GoSentry job ran") + } + job.Arguments = strings.TrimSpace(job.Arguments) + job.SuccessExitCodes = strings.TrimSpace(job.SuccessExitCodes) + if job.SuccessExitCodes == "" { + job.SuccessExitCodes = "0" + } + if job.LastRun == "" { + job.LastRun = "Never" + } + if job.Output == "" { + job.Output = "No command output captured yet." + } + if job.Enabled { + job.LastState = "Ready" + job.NextRun = "After start" + } else { + job.LastState = "Paused" + job.NextRun = "Paused" + } + // Runtime fields are reconstructed each time the app starts. Persisted run + // records live in log files, not in jobs.yaml, to keep the jobs file easy + // to review and edit by hand. + job.Logs = nil + } +} + +func resolveJobsDir(appDir string, jobsDir string) string { + return resolveConfiguredDir(appDir, jobsDir) +} + +func resolveConfiguredDir(appDir string, dir string) string { + if filepath.IsAbs(dir) { + return dir + } + // Relative paths are resolved against the executable directory, not the + // process working directory. This matches ResolvePaths and keeps shortcuts, + // Explorer launches, and terminal launches consistent. + return filepath.Clean(filepath.Join(appDir, dir)) +} + +func (s *Store) applyConfigPaths() { + s.Paths.JobsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.JobsDir) + s.Paths.JobsPath = filepath.Join(s.Paths.JobsDir, JobsFileName) + s.Paths.LogsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.LogsDir) +} + +func writeYAML(path string, value any) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := yaml.Marshal(value) + if err != nil { + return err + } + // WriteFile replaces the full file instead of patching it in place. For small + // YAML files this is simpler and prevents stale keys from older versions from + // lingering after the schema changes. + return os.WriteFile(path, data, 0o644) +} + +func defaultJobs() []domain.Job { + return []domain.Job{ + { + ID: 1, + Name: "Hello scheduler", + Folder: "Examples", + Schedule: "@every 1m", + Command: echoCommand("GoSentry test job: scheduler is alive"), + Enabled: true, + }, + { + ID: 2, + Name: "Write timestamp", + Folder: "Examples", + Schedule: "*/1 * * * *", + Command: echoCommand("GoSentry test job: timestamp command ran"), + Enabled: true, + }, + { + ID: 3, + Name: "Paused sample", + Schedule: "@every 1m", + Command: echoCommand("This paused sample should not run until enabled"), + Enabled: false, + }, + } +} + +func echoCommand(message string) string { + if runtime.GOOS == "windows" { + return "echo " + message + } + // POSIX shells need quotes for messages with spaces. Single quotes inside the + // message are escaped using the standard close-quote/backslash/reopen pattern. + return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'" +} diff --git a/src/storage/store_test.go b/src/storage/store_test.go new file mode 100644 index 0000000..4dd5c4e --- /dev/null +++ b/src/storage/store_test.go @@ -0,0 +1,205 @@ +package storage + +import ( + "path/filepath" + "strings" + "testing" + + "gitea.mixdep.ru/mix/gosentry/src/domain" + "go.yaml.in/yaml/v4" +) + +func TestJobsRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "jobs.yaml") + + original := []domain.Job{ + { + ID: 7, + Name: "Backup data", + Folder: "Maintenance", + Schedule: "0 2 * * *", + Command: "/usr/bin/backup", + Arguments: "--compress\n--verbose", + SuccessExitCodes: "0,1", + StartOnly: true, + Enabled: true, + }, + } + + if err := writeYAML(path, domain.JobsFile{Jobs: original}); err != nil { + t.Fatal(err) + } + + got, err := loadOrCreateJobs(path) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Fatalf("expected 1 job, got %d", len(got)) + } + + g, w := got[0], original[0] + if g.ID != w.ID { + t.Errorf("ID: got %d, want %d", g.ID, w.ID) + } + if g.Name != w.Name { + t.Errorf("Name: got %q, want %q", g.Name, w.Name) + } + if g.Folder != w.Folder { + t.Errorf("Folder: got %q, want %q", g.Folder, w.Folder) + } + if g.Schedule != w.Schedule { + t.Errorf("Schedule: got %q, want %q", g.Schedule, w.Schedule) + } + if g.Command != w.Command { + t.Errorf("Command: got %q, want %q", g.Command, w.Command) + } + if g.Arguments != w.Arguments { + t.Errorf("Arguments: got %q, want %q", g.Arguments, w.Arguments) + } + if g.SuccessExitCodes != w.SuccessExitCodes { + t.Errorf("SuccessExitCodes: got %q, want %q", g.SuccessExitCodes, w.SuccessExitCodes) + } + if g.StartOnly != w.StartOnly { + t.Errorf("StartOnly: got %v, want %v", g.StartOnly, w.StartOnly) + } + if g.Enabled != w.Enabled { + t.Errorf("Enabled: got %v, want %v", g.Enabled, w.Enabled) + } + + // Runtime fields must not survive the save→load round-trip. + if g.LastRun != "" { + t.Errorf("LastRun should be empty after load, got %q", g.LastRun) + } + if g.LastState != "" { + t.Errorf("LastState should be empty after load, got %q", g.LastState) + } + if g.Logs != nil { + t.Errorf("Logs should be nil after load, got %v", g.Logs) + } +} + +func TestConfigRoundTrip(t *testing.T) { + dir := t.TempDir() + paths := Paths{ + AppDir: dir, + ConfigPath: filepath.Join(dir, ConfigFileName), + } + + want := domain.Config{ + JobsDir: "/custom/jobs", + LogsDir: "/custom/logs", + MaxLogFiles: 50, + MaxLogAgeDays: 14, + StartOnLogin: true, + KeepRunningInTray: false, + NotifyOnFailure: false, + } + if err := writeYAML(paths.ConfigPath, want); err != nil { + t.Fatal(err) + } + + got, err := loadOrCreateConfig(paths) + if err != nil { + t.Fatal(err) + } + + if got.JobsDir != want.JobsDir { + t.Errorf("JobsDir: got %q, want %q", got.JobsDir, want.JobsDir) + } + if got.LogsDir != want.LogsDir { + t.Errorf("LogsDir: got %q, want %q", got.LogsDir, want.LogsDir) + } + if got.MaxLogFiles != want.MaxLogFiles { + t.Errorf("MaxLogFiles: got %d, want %d", got.MaxLogFiles, want.MaxLogFiles) + } + if got.MaxLogAgeDays != want.MaxLogAgeDays { + t.Errorf("MaxLogAgeDays: got %d, want %d", got.MaxLogAgeDays, want.MaxLogAgeDays) + } + if got.StartOnLogin != want.StartOnLogin { + t.Errorf("StartOnLogin: got %v, want %v", got.StartOnLogin, want.StartOnLogin) + } + if got.KeepRunningInTray != want.KeepRunningInTray { + t.Errorf("KeepRunningInTray: got %v, want %v", got.KeepRunningInTray, want.KeepRunningInTray) + } + if got.NotifyOnFailure != want.NotifyOnFailure { + t.Errorf("NotifyOnFailure: got %v, want %v", got.NotifyOnFailure, want.NotifyOnFailure) + } +} + +func TestNormalizeJobsFillsDefaults(t *testing.T) { + jobs := []domain.Job{ + {Enabled: true}, + {Enabled: false}, + {ID: 5, Name: "Kept", Schedule: "*/10 * * * *", SuccessExitCodes: "0,1", Enabled: true}, + } + + normalizeJobs(jobs) + + // Blank enabled job gets default name, schedule, command, exit codes, and runtime state. + if jobs[0].ID != 1 { + t.Errorf("first auto ID: got %d, want 1", jobs[0].ID) + } + if jobs[0].Name != "Untitled job" { + t.Errorf("default name: got %q, want 'Untitled job'", jobs[0].Name) + } + if jobs[0].Schedule != "@every 1m" { + t.Errorf("default schedule: got %q, want '@every 1m'", jobs[0].Schedule) + } + if jobs[0].SuccessExitCodes != "0" { + t.Errorf("default exit codes: got %q, want '0'", jobs[0].SuccessExitCodes) + } + if jobs[0].LastState != "Ready" { + t.Errorf("enabled job state: got %q, want 'Ready'", jobs[0].LastState) + } + if jobs[0].NextRun != "After start" { + t.Errorf("enabled job next run: got %q, want 'After start'", jobs[0].NextRun) + } + + // Disabled job is marked Paused. + if jobs[1].LastState != "Paused" { + t.Errorf("disabled job state: got %q, want 'Paused'", jobs[1].LastState) + } + if jobs[1].NextRun != "Paused" { + t.Errorf("disabled job next run: got %q, want 'Paused'", jobs[1].NextRun) + } + + // Pre-set fields survive normalization unchanged. + if jobs[2].ID != 5 { + t.Errorf("pre-set ID should be preserved: got %d, want 5", jobs[2].ID) + } + if jobs[2].SuccessExitCodes != "0,1" { + t.Errorf("pre-set exit codes should be preserved: got %q, want '0,1'", jobs[2].SuccessExitCodes) + } +} + +func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) { + jobs := []domain.Job{ + { + ID: 1, + Name: "Clean job", + Schedule: "@every 10s", + Command: echoCommand("ok"), + Enabled: true, + LastRun: "2026-06-14 12:00:00", + NextRun: "2026-06-14 12:00:10", + LastState: "OK", + Output: "stdout: ok", + Logs: []domain.RunRecord{ + {Time: "2026-06-14 12:00:00", JobName: "Clean job", Output: "stdout: ok"}, + }, + }, + } + + data, err := yaml.Marshal(domain.JobsFile{Jobs: jobs}) + if err != nil { + t.Fatal(err) + } + text := string(data) + for _, unwanted := range []string{"last_run", "next_run", "last_state", "activity", "last_output", "stdout"} { + if strings.Contains(text, unwanted) { + t.Fatalf("jobs yaml should not contain %q:\n%s", unwanted, text) + } + } +}