diff --git a/cmd/jobsearch/main.go b/cmd/jobsearch/main.go new file mode 100644 index 0000000..f2b71e4 --- /dev/null +++ b/cmd/jobsearch/main.go @@ -0,0 +1,85 @@ +/* +Copyright © 2026 Craig Williams + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with this program. If not, see . +*/ +// Command job-search. Run `job-search --help` for usage. +package main + +import ( + "fmt" + "os" + + "log/slog" + "strings" + + "git.avsdev.uk/Go/job-search/internal/cli" + "git.avsdev.uk/Go/job-search/internal/cli/config" + "git.avsdev.uk/Go/job-search/internal/cli/version" + "github.com/spf13/cobra" +) + +// newRootCmd builds the command tree. It returns a fresh tree on each call so +// tests can run commands in isolation. +func newRootCmd() *cobra.Command { + opts := &cli.Options{} + + root := &cobra.Command{ + Use: "job-search", + Short: "JobSearch - AVSDev Future Opportunities!", + Long: `JobSearch is a scraping tool for searching for possible contract +opportunities. + `, + SilenceUsage: true, // don't print usage on runtime errors + SilenceErrors: true, // main prints the error once + CompletionOptions: cobra.CompletionOptions{ + HiddenDefaultCmd: true, + }, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + cli.SetupIntialLogging(cmd) + + slog.Debug("root.PersistentPreRun") + slog.Debug( + "command: \"" + + strings.Join(strings.Split(cmd.CommandPath(), " ")[1:], " ") + + "\"", + ) + + return nil + }, + PersistentPostRunE: func(cmd *cobra.Command, args []string) error { + slog.Debug("root.PersistentPostRun") + + return opts.Finish() + }, + } + + cobra.EnableTraverseRunHooks = true + + opts.Bind(root) + + root.AddCommand( + version.NewCommand(), + config.NewCommand(opts), + ) + + return root +} + +func main() { + if err := newRootCmd().Execute(); err != nil { + fmt.Fprintln(os.Stderr, "job-search:", err) + os.Exit(1) + } +} diff --git a/cmd/root.go b/cmd/root.go deleted file mode 100644 index 8153ad3..0000000 --- a/cmd/root.go +++ /dev/null @@ -1,118 +0,0 @@ -/* -Copyright © 2026 Craig Williams - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Lesser General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public License -along with this program. If not, see . -*/ -package cmd - -import ( - "log/slog" - "os" - "strings" - - "git.avsdev.uk/Go/job-search/internal/cli" - cmdConfig "git.avsdev.uk/Go/job-search/internal/cli/config" - cmdVersion "git.avsdev.uk/Go/job-search/internal/cli/version" - "git.avsdev.uk/Go/job-search/internal/config" - "git.avsdev.uk/Go/job-search/internal/log" - "github.com/spf13/cobra" -) - -var rootCmd = &cobra.Command{ - Use: "job-search", - Short: "JobSearch - AVSDev Future Opportunities!", - Long: `JobSearch is a scraping tool for searching for possible contract -opportunities. - `, - CompletionOptions: cobra.CompletionOptions{ - HiddenDefaultCmd: true, - }, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - // Check if the command is expecting to use terminal for messages - if val, ok := cmd.Annotations["terminal_log"]; ok && val == "1" { - log.LogToTerminal() - } - - // Check for debug level via args - dbgCount, _ := cmd.Flags().GetCount("debug") - conf := config.JobSearchConfig{ - Logging: config.JobSearchConfigLogging{}, - } - if dbgCount > 1 { - conf.Logging.Level = "debug" - } else if dbgCount > 0 { - conf.Logging.Level = "info" - } - if conf.Logging.LogLevel() <= slog.LevelDebug { - log.EnableDebug() - } else if conf.Logging.LogLevel() <= slog.LevelInfo { - log.EnableInfo() - } - - // With logging set up, these will be visible if trace is active - slog.Debug("root.PersistentPreRun") - slog.Debug( - "command: \"" + - strings.Join(strings.Split(cmd.CommandPath(), " ")[1:], " ") + - "\"", - ) - - // Always load config unless command indicates not to - if val, ok := cmd.Annotations["no_config"]; !ok || val == "0" { - // Load config - from specified file if provided - cfgFile, _ := cmd.Flags().GetString("config") - if err := config.Init(cfgFile, &conf); err != nil { - return err - } - - // Attach config to command context - ctx := cli.WithConfig(cmd.Context(), conf) - cmd.SetContext(ctx) - - // Change logging level based off newly loaded config - if conf.Logging.LogLevel() <= slog.LevelDebug { - log.EnableDebug() - } - } - - return nil - }, - PersistentPostRun: func(cmd *cobra.Command, args []string) { - slog.Debug("root.PersistentPostRun") - }, -} - -// Execute, called by main.main() handles all child commands and appropriate -// flags. -func Execute() { - err := rootCmd.Execute() - if err != nil { - os.Exit(1) - } -} - -func init() { - cobra.EnableTraverseRunHooks = true - - log.Init() - - pFlags := rootCmd.PersistentFlags() - pFlags.StringP( - "config", "c", "", - "config file (default: "+config.EtcConfigPath+"/config.yaml)") - pFlags.CountP("debug", "d", "enable debug output (repeat for trace)") - - rootCmd.AddCommand(cmdVersion.NewCommand()) - rootCmd.AddCommand(cmdConfig.NewCommand()) -} diff --git a/internal/cli/config/command.go b/internal/cli/config/command.go index 4462f19..a55a8d7 100644 --- a/internal/cli/config/command.go +++ b/internal/cli/config/command.go @@ -19,10 +19,11 @@ package config import ( "log/slog" + "git.avsdev.uk/Go/job-search/internal/cli" "github.com/spf13/cobra" ) -func NewCommand() *cobra.Command { +func NewCommand(opts *cli.Options) *cobra.Command { cmd := &cobra.Command{ Use: "config", Short: "Configuration management", @@ -33,9 +34,9 @@ func NewCommand() *cobra.Command { slog.Debug("config.PersistentPostRun") }, } - cmd.AddCommand(commandShow()) - cmd.AddCommand(commandGet()) - cmd.AddCommand(commandSet()) + cmd.AddCommand(commandShow(opts)) + cmd.AddCommand(commandGet(opts)) + cmd.AddCommand(commandSet(opts)) cmd.AddCommand(commandInit()) return cmd } diff --git a/internal/cli/config/get.go b/internal/cli/config/get.go index 6ffbdc3..dc24730 100644 --- a/internal/cli/config/get.go +++ b/internal/cli/config/get.go @@ -17,7 +17,6 @@ along with this program. If not, see . package config import ( - "errors" "fmt" "log/slog" @@ -25,7 +24,7 @@ import ( "github.com/spf13/cobra" ) -func commandGet() *cobra.Command { +func commandGet(opts *cli.Options) *cobra.Command { cmd := &cobra.Command{ Use: "get [config_key]", Args: cobra.ExactArgs(1), @@ -43,9 +42,9 @@ func commandGet() *cobra.Command { return err } - conf, ok := cli.ConfigFromContext(cmd.Context()) - if !ok { - return errors.New("no configuration loaded") + conf, err := opts.RunSetup(cmd, true, true) + if err != nil { + return fmt.Errorf("no configuration loaded (%w)", err) } v, err := conf.ValueOf(args[0]) @@ -57,9 +56,6 @@ func commandGet() *cobra.Command { return nil }, - Annotations: map[string]string{ - "terminal_log": "1", - }, } return cmd } diff --git a/internal/cli/config/init.go b/internal/cli/config/init.go index 7e6b543..cbec792 100644 --- a/internal/cli/config/init.go +++ b/internal/cli/config/init.go @@ -87,10 +87,6 @@ func commandInit() *cobra.Command { } return nil }, - Annotations: map[string]string{ - "no_config": "1", - "terminal_log": "1", - }, } return cmd } diff --git a/internal/cli/config/set.go b/internal/cli/config/set.go index 195e5eb..9d46952 100644 --- a/internal/cli/config/set.go +++ b/internal/cli/config/set.go @@ -29,7 +29,7 @@ import ( "github.com/spf13/viper" ) -func setConfig(conf config.JobSearchConfig, key string, value string) error { +func setConfig(conf *config.JobSearchConfig, key string, value string) error { var confValue any = nil var confType reflect.Kind var err error @@ -65,7 +65,7 @@ func setConfig(conf config.JobSearchConfig, key string, value string) error { return nil } -func unsetConfig(conf config.JobSearchConfig, key string) error { +func unsetConfig(conf *config.JobSearchConfig, key string) error { if _, err := conf.TypeOf(key); err != nil { return err } @@ -88,7 +88,7 @@ func unsetConfig(conf config.JobSearchConfig, key string) error { return nil } -func commandSet() *cobra.Command { +func commandSet(opts *cli.Options) *cobra.Command { cmd := &cobra.Command{ Use: "set [config_key] [config_value]", Args: cobra.MatchAll(cobra.MinimumNArgs(1), cobra.MaximumNArgs(2)), @@ -117,9 +117,9 @@ func commandSet() *cobra.Command { return err } - conf, ok := cli.ConfigFromContext(cmd.Context()) - if !ok { - return errors.New("no configuration loaded") + conf, err := opts.RunSetup(cmd, true, true) + if err != nil { + return fmt.Errorf("no configuration loaded (%w)", err) } if viper.ConfigFileUsed() == "" { @@ -132,9 +132,6 @@ func commandSet() *cobra.Command { return setConfig(conf, args[0], args[1]) } }, - Annotations: map[string]string{ - "terminal_log": "1", - }, } cmd.Flags().BoolP("unset", "x", false, "clear configuration setting") return cmd diff --git a/internal/cli/config/show.go b/internal/cli/config/show.go index 0b4c862..cac2fc8 100644 --- a/internal/cli/config/show.go +++ b/internal/cli/config/show.go @@ -17,14 +17,14 @@ along with this program. If not, see . package config import ( - "errors" + "fmt" "log/slog" "git.avsdev.uk/Go/job-search/internal/cli" "github.com/spf13/cobra" ) -func commandShow() *cobra.Command { +func commandShow(opts *cli.Options) *cobra.Command { cmd := &cobra.Command{ Use: "show", Short: "Print config information", @@ -37,9 +37,9 @@ func commandShow() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { slog.Debug("config.show.Run") - conf, ok := cli.ConfigFromContext(cmd.Context()) - if !ok { - return errors.New("no configuration loaded") + conf, err := opts.RunSetup(cmd, true, true) + if err != nil { + return fmt.Errorf("no configuration loaded (%w)", err) } if yaml, _ := cmd.Flags().GetBool("yaml"); yaml { @@ -52,9 +52,6 @@ func commandShow() *cobra.Command { return nil }, - Annotations: map[string]string{ - "terminal_log": "1", - }, } cmd.Flags().Bool("json", false, "format configuration as json") cmd.Flags().Bool("yaml", false, "format configuration as yaml") diff --git a/main.go b/internal/cli/logging.go similarity index 56% rename from main.go rename to internal/cli/logging.go index 323e8c7..216b560 100644 --- a/main.go +++ b/internal/cli/logging.go @@ -14,12 +14,28 @@ GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this program. If not, see . */ -package main +package cli import ( - "git.avsdev.uk/Go/job-search/cmd" + "log/slog" + + "git.avsdev.uk/Go/job-search/internal/log" + "github.com/spf13/cobra" ) -func main() { - cmd.Execute() +func SetupIntialLogging(cmd *cobra.Command) { + // Start logging to terminal first - any commands that want to use file + // logging should activate it themselves + log.LogToTerminal() + + // Check for quiet flag + if quiet, _ := cmd.Flags().GetBool("quiet"); quiet { + log.SetLevel(slog.LevelError) + } + + // Check for debug logging enabled (overrides quiet flag) + debugEnabled, _ := cmd.Flags().GetBool("debug") + if debugEnabled { + log.EnableDebug() + } } diff --git a/internal/cli/options.go b/internal/cli/options.go new file mode 100644 index 0000000..bed4813 --- /dev/null +++ b/internal/cli/options.go @@ -0,0 +1,89 @@ +/* +Copyright © 2026 Craig Williams + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with this program. If not, see . +*/ +package cli + +import ( + "log/slog" + + "git.avsdev.uk/Go/job-search/internal/config" + "git.avsdev.uk/Go/job-search/internal/log" + "github.com/spf13/cobra" +) + +type Options struct { + configFile string + debug bool + quiet bool +} + +// Binds common options through the root command +func (o *Options) Bind(cmd *cobra.Command) { + pf := cmd.PersistentFlags() + pf.StringVarP(&o.configFile, "config", "c", "", "configuration file") + pf.BoolVarP(&o.debug, "debug", "d", false, "enable debug messages") + pf.BoolVarP( + &o.quiet, "quiet", "q", false, "don't report info or warning messages", + ) +} + +// Setup loads the config and ensure logging is switch to file if needed +func (o *Options) RunSetup( + cmd *cobra.Command, loadConfig bool, terminalLog bool, +) (*config.JobSearchConfig, error) { + // Check for debug level or quiet supression via args + conf := &config.JobSearchConfig{ + Logging: config.JobSearchConfigLogging{}, + } + if o.debug { + conf.Logging.Level = "debug" + } else if o.quiet { + conf.Logging.Level = "error" + } + + // Only load config if command requests it (nearly always true) + if loadConfig { + // Load config - from specified file if provided + if err := config.Init(o.configFile, conf); err != nil { + return nil, err + } + + // Change logging level based off newly loaded config + if conf.Logging.LogLevel() <= slog.LevelDebug && !log.IsDebugEnabled() { + log.EnableDebug() + } + + // Check if the logging should be switched to file + if !terminalLog { + if len(conf.Logging.File) == 0 { + slog.Warn("No log file configured, remaining in console logging mode!") + } else { + log.LogToFile(conf.Logging.File) + } + } + } + + return conf, nil +} + +// Finish shuts down any open file logger +func (o *Options) Finish() error { + if log.IsFileLogger() { + log.Shutdown() + } + + return nil +} diff --git a/internal/cli/version/command.go b/internal/cli/version/command.go index 0366861..1f5e45f 100644 --- a/internal/cli/version/command.go +++ b/internal/cli/version/command.go @@ -37,7 +37,7 @@ func NewCommand() *cobra.Command { Run: func(cmd *cobra.Command, args []string) { slog.Debug("version.Run") - fmt.Printf("PBCNinja - %s\n", version.Version) + fmt.Printf("JobSearch - %s\n", version.Version) if version.BuildTime != "unknown" { fmt.Printf("Built: %s\n", version.BuildTime) } @@ -45,10 +45,6 @@ func NewCommand() *cobra.Command { fmt.Printf("Commit: %s\n", version.GitCommit) } }, - Annotations: map[string]string{ - "no_config": "1", - "terminal_log": "1", - }, } return cmd } diff --git a/internal/cli/with_config.go b/internal/cli/with_config.go deleted file mode 100644 index ee4c649..0000000 --- a/internal/cli/with_config.go +++ /dev/null @@ -1,43 +0,0 @@ -/* -Copyright © 2026 Craig Williams - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Lesser General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public License -along with this program. If not, see . -*/ -package cli - -import ( - "context" - - "git.avsdev.uk/Go/job-search/internal/config" -) - -// Define a private key type to avoid collisions -type configContextKey struct { - name string -} - -// Define the key -var configKey = &configContextKey{"config"} - -// Public accessor to store a value to ctx -func WithConfig(ctx context.Context, conf config.JobSearchConfig) context.Context { - // No allocation here since userIDKey is a pointer to a struct - return context.WithValue(ctx, configKey, conf) -} - -// Public accessor to fetch a value from ctx -func ConfigFromContext(ctx context.Context) (config.JobSearchConfig, bool) { - v, ok := ctx.Value(configKey).(config.JobSearchConfig) - return v, ok -} diff --git a/internal/config/conf_logging.go b/internal/config/conf_logging.go index 4b24622..98fb55e 100644 --- a/internal/config/conf_logging.go +++ b/internal/config/conf_logging.go @@ -24,6 +24,7 @@ import ( type JobSearchConfigLogging struct { Level string + File string } // Default configuration @@ -37,6 +38,7 @@ func defaultConfigLogging() JobSearchConfigLogging { func (conf JobSearchConfigLogging) Map() map[string]any { return map[string]any{ "level": conf.Level, + "file": conf.File, } } @@ -55,6 +57,8 @@ func (c JobSearchConfigLogging) TypeOf(key string) (reflect.Kind, error) { switch key { case "level": return reflect.String, nil + case "file": + return reflect.String, nil default: return reflect.Invalid, errors.New("unknown configuration key") } @@ -65,6 +69,8 @@ func (c JobSearchConfigLogging) ValueOf(key string) (any, error) { switch key { case "level": return c.Level, nil + case "file": + return c.File, nil default: return "", errors.New("unknown configuration key") } @@ -82,12 +88,15 @@ func (c *JobSearchConfigLogging) SetValue(key string, value any) error { return errors.New( "invalid log level. Must be one of: debug, info, warn, error") } + case "file": + c.File = value.(string) default: return errors.New("unknown configuration key") } return nil } +// Utility methods for converting level strings to slog levels func ConfigLevelToLogLevel(level string) slog.Level { switch level { case "debug": @@ -102,7 +111,6 @@ func ConfigLevelToLogLevel(level string) slog.Level { return 99 } } - func (c *JobSearchConfigLogging) LogLevel() slog.Level { return ConfigLevelToLogLevel(c.Level) } diff --git a/internal/config/load.go b/internal/config/load.go index 4595f35..b5de9e3 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -81,10 +81,14 @@ func Init(cfgFile string, conf *JobSearchConfig) error { return err } - oldLogLevel := conf.Logging.Level + // Load configuration from Viper, ensure -debug and -quiet are retained + oldLogLevel := conf.Logging.LogLevel() LoadFromViper(conf) - if ConfigLevelToLogLevel(oldLogLevel) < conf.Logging.LogLevel() { - conf.Logging.Level = oldLogLevel + if oldLogLevel == slog.LevelDebug { + conf.Logging.Level = "debug" + } + if oldLogLevel == slog.LevelError { + conf.Logging.Level = "error" } if conf.Logging.LogLevel() <= slog.LevelDebug { diff --git a/internal/log/logging.go b/internal/log/logging.go index 18d373f..0a187ce 100644 --- a/internal/log/logging.go +++ b/internal/log/logging.go @@ -26,24 +26,11 @@ import ( ) var logLevel slog.LevelVar +var fileLogger *os.File -// Set up the logger using defaults -func Init() { - logLevel.Set(slog.LevelWarn) - opts := &slog.HandlerOptions{ - Level: &logLevel, - } - handler := slog.NewTextHandler(os.Stdout, opts) - logger := slog.New(handler) - slog.SetDefault(logger) -} - -// Enable info level logging -func EnableInfo() { - if logLevel.Level() > slog.LevelInfo { - logLevel.Set(slog.LevelInfo) - slog.Info("Info logging enabled") - } +// Set the logging to a particular level, quietly +func SetLevel(level slog.Level) { + logLevel.Set(level) } // Enable debug level logging @@ -54,6 +41,36 @@ func EnableDebug() { } } +func LogToFile(logfile string) error { + if fileLogger != nil { + (*fileLogger).Close() + } + + // Open the log file + fileLogger, err := os.OpenFile(logfile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + return err + } + + opts := &slog.HandlerOptions{ + Level: &logLevel, + } + handler := slog.NewJSONHandler(fileLogger, opts) + logger := slog.New(handler) + slog.SetDefault(logger) + + return nil +} + +func Shutdown() error { + var err error = nil + if fileLogger != nil { + err = (*fileLogger).Close() + fileLogger = nil + } + return err +} + func LogToTerminal() { opts := &tint.Options{ Level: &logLevel, @@ -94,3 +111,11 @@ func IsJSONHandler(h slog.Handler) bool { // Read the boolean value using unsafe return *(*bool)(unsafe.Pointer(jsonField.UnsafeAddr())) } + +func IsFileLogger() bool { + return fileLogger != nil +} + +func IsDebugEnabled() bool { + return logLevel.Level() <= slog.LevelDebug +}