From e3c379d069ffa9661561d25cdbf2f5894a2f8ee8 Mon Sep 17 00:00:00 2001 From: Adam Mathes Date: Sat, 14 Feb 2026 08:58:38 -0800 Subject: Refactor: project structure, implement dependency injection, and align v2 UI with v1 --- cmd/neko/main.go | 152 ++++++++++++++++++++++++++++++++++++++++++++++++++ cmd/neko/main_test.go | 124 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 cmd/neko/main.go create mode 100644 cmd/neko/main_test.go (limited to 'cmd') diff --git a/cmd/neko/main.go b/cmd/neko/main.go new file mode 100644 index 0000000..47385b1 --- /dev/null +++ b/cmd/neko/main.go @@ -0,0 +1,152 @@ +package main + +import ( + "fmt" + "os" + "time" + + "adammathes.com/neko/config" + "adammathes.com/neko/internal/crawler" + "adammathes.com/neko/internal/exporter" + "adammathes.com/neko/models" + "adammathes.com/neko/models/feed" + + "flag" + + "adammathes.com/neko/internal/vlog" + "adammathes.com/neko/web" +) + +var Version, Build string + +func main() { + if err := Run(os.Args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } +} + +func Run(args []string) error { + var help, update, verbose, proxyImages bool + var configFile, dbfile, newFeed, export, password string + var port, minutes int + + f := flag.NewFlagSet("neko", flag.ContinueOnError) + + // config file + f.StringVar(&configFile, "config", "", "read configuration from file") + f.StringVar(&configFile, "c", "", "read configuration from file (short)") + + // commands + f.BoolVar(&help, "help", false, "display help") + f.BoolVar(&help, "h", false, "display help (short)") + + f.BoolVar(&update, "update", false, "fetch feeds and store new items") + f.BoolVar(&update, "u", false, "fetch feeds and store new items (short)") + + f.StringVar(&newFeed, "add", "", "add the feed at URL") + f.StringVar(&newFeed, "a", "", "add the feed at URL (short)") + + f.StringVar(&export, "export", "", "export feed: text, opml, html, json") + f.StringVar(&export, "x", "", "export feed (short)") + + // options + f.StringVar(&dbfile, "database", "", "sqlite database file") + f.StringVar(&dbfile, "d", "", "sqlite database file (short)") + + f.IntVar(&port, "http", 0, "HTTP port to serve on") + f.IntVar(&port, "s", 0, "HTTP port to serve on (short)") + + f.IntVar(&minutes, "minutes", 0, "minutes between crawling feeds") + f.IntVar(&minutes, "m", 0, "minutes between crawling feeds (short)") + + f.BoolVar(&proxyImages, "imageproxy", false, "rewrite and proxy all image requests") + f.BoolVar(&proxyImages, "i", false, "rewrite and proxy all image requests (short)") + + f.BoolVar(&verbose, "verbose", false, "verbose output") + f.BoolVar(&verbose, "v", false, "verbose output (short)") + + f.StringVar(&password, "password", "", "password for web interface") + f.StringVar(&password, "p", "", "password for web interface (short)") + + f.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) + f.PrintDefaults() + } + + if err := f.Parse(args); err != nil { + return err + } + + if help { + fmt.Printf("neko v%s | build %s\n", Version, Build) + f.Usage() + return nil + } + // reads config if present and sets defaults + if err := config.Init(configFile); err != nil { + return fmt.Errorf("config error: %v", err) + } + + // override config file with flags if present + vlog.VERBOSE = verbose + if dbfile != "" { + config.Config.DBFile = dbfile + } + + if port != 0 { + config.Config.Port = port + } + + if password != "" { + config.Config.DigestPassword = password + } + + if minutes != 0 { + config.Config.CrawlMinutes = minutes + } + + if proxyImages != false { + config.Config.ProxyImages = proxyImages + } + + models.InitDB() + + if update { + vlog.Printf("starting crawl\n") + crawler.Crawl() + return nil + } + if newFeed != "" { + vlog.Printf("creating new feed\n") + feed.NewFeed(newFeed) + return nil + } + if export != "" { + vlog.Printf("exporting feeds in format %s\n", export) + fmt.Printf("%s", exporter.ExportFeeds(export)) + return nil + } + + // For testing, we might want to avoid starting a web server + if config.Config.Port == -1 { + return nil + } + + go backgroundCrawl(config.Config.CrawlMinutes) + vlog.Printf("starting web server at 127.0.0.1:%d\n", + config.Config.Port) + web.Serve(&config.Config) + return nil +} + +func backgroundCrawl(minutes int) { + if minutes < 1 { + return + } + vlog.Printf("starting background crawl every %d minutes\n", minutes) + for { + time.Sleep(time.Minute * time.Duration(minutes)) + crawler.Crawl() + } +} diff --git a/cmd/neko/main_test.go b/cmd/neko/main_test.go new file mode 100644 index 0000000..fd36fdd --- /dev/null +++ b/cmd/neko/main_test.go @@ -0,0 +1,124 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "adammathes.com/neko/config" + "adammathes.com/neko/models" +) + +func TestRunHelp(t *testing.T) { + err := Run([]string{"--help"}) + if err != nil { + t.Errorf("Run(--help) should not error, got %v", err) + } +} + +func TestRunInvalidFlag(t *testing.T) { + err := Run([]string{"--invalid"}) + if err == nil { + t.Error("Run(--invalid) should error") + } +} + +func TestRunCrawl(t *testing.T) { + // Setup test DB + config.Config.DBFile = filepath.Join(t.TempDir(), "test_main.db") + models.InitDB() + defer models.DB.Close() + + // Use --update flag + err := Run([]string{"-u", "-d", config.Config.DBFile}) + if err != nil { + t.Errorf("Run(-u) should not error, got %v", err) + } +} + +func TestBackgroundCrawlZero(t *testing.T) { + backgroundCrawl(0) // Should return immediately +} + +func TestRunServerConfig(t *testing.T) { + // Setup test DB + config.Config.DBFile = filepath.Join(t.TempDir(), "test_main_server.db") + models.InitDB() + defer models.DB.Close() + + // Use config.Config.Port = -1 to signal Run to exit instead of starting server + config.Config.Port = -1 + err := Run([]string{"-d", config.Config.DBFile}) + if err != nil { + t.Errorf("Run should not error with Port=-1, got %v", err) + } +} + +func TestRunAdd(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test_add.db") + err := Run([]string{"-d", dbPath, "-a", "http://example.com/rss"}) + if err != nil { + t.Errorf("Run -a failed: %v", err) + } +} + +func TestRunExport(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test_export.db") + err := Run([]string{"-d", dbPath, "-x", "text"}) + if err != nil { + t.Errorf("Run -x failed: %v", err) + } +} + +func TestRunOptions(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test_options.db") + err := Run([]string{"-d", dbPath, "-v", "-i", "-s", "-1"}) + if err != nil { + t.Errorf("Run with options failed: %v", err) + } +} + +func TestRunSetPassword(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test_pass.db") + err := Run([]string{"-d", dbPath, "-p", "newpassword"}) + if err != nil { + t.Errorf("Run -p should succeed, got %v", err) + } + if config.Config.DigestPassword != "newpassword" { + t.Errorf("Expected password to be updated") + } +} + +func TestRunConfigError(t *testing.T) { + err := Run([]string{"-c", "/nonexistent/config.yaml"}) + if err == nil { + t.Error("Run should error for nonexistent config file") + } +} + +func TestRunExportFormat(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test_export_format.db") + err := Run([]string{"-d", dbPath, "-x", "json"}) + if err != nil { + t.Errorf("Run -x json failed: %v", err) + } +} + +func TestRunConfigInvalidContent(t *testing.T) { + tmpDir := t.TempDir() + confPath := filepath.Join(tmpDir, "bad.yaml") + os.WriteFile(confPath, []byte("invalid: : yaml"), 0644) + err := Run([]string{"-c", confPath}) + if err == nil { + t.Error("Run should error for malformed config file") + } +} + +func TestRunNoArgs(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test_noargs.db") + config.Config.Port = -1 + err := Run([]string{"-d", dbPath}) + if err != nil { + t.Errorf("Run with no args failed: %v", err) + } +} -- cgit v1.2.3