about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--go.mod1
-rw-r--r--go.sum3
-rw-r--r--gomod2nix.toml3
-rw-r--r--import/main.go116
-rw-r--r--internal/config/config.go157
-rw-r--r--internal/importer/main.go90
-rw-r--r--internal/server/headers.go4
-rw-r--r--internal/server/mux.go65
-rw-r--r--internal/server/server.go7
-rw-r--r--nix/modules/default.nix135
-rw-r--r--serve/main.go24
11 files changed, 306 insertions, 299 deletions
diff --git a/go.mod b/go.mod
index 1a81e20..17d0ed4 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,6 @@ module searchix
 go 1.22.2
 
 require (
-	github.com/ardanlabs/conf/v3 v3.1.7
 	github.com/bcicen/jstream v1.0.1
 	github.com/blevesearch/bleve/v2 v2.4.0
 	github.com/blevesearch/bleve_index_api v1.1.6
diff --git a/go.sum b/go.sum
index a61a78e..519ca16 100644
--- a/go.sum
+++ b/go.sum
@@ -1,7 +1,5 @@
 github.com/RoaringBitmap/roaring v1.9.3 h1:t4EbC5qQwnisr5PrP9nt0IRhRTb9gMUgQF4t4S2OByM=
 github.com/RoaringBitmap/roaring v1.9.3/go.mod h1:6AXUsoIEzDTFFQCe1RbGA6uFONMhvejWj5rqITANK90=
-github.com/ardanlabs/conf/v3 v3.1.7 h1:p232cF68TafoA5U9ZlbxUIhGJtGNdKHBXF80Fdqb5t0=
-github.com/ardanlabs/conf/v3 v3.1.7/go.mod h1:zclexWKe0NVj6LHQ8NgDDZ7bQ1spE0KeKPFficdtAjU=
 github.com/bcicen/jstream v1.0.1 h1:BXY7Cu4rdmc0rhyTVyT3UkxAiX3bnLpKLas9btbH5ck=
 github.com/bcicen/jstream v1.0.1/go.mod h1:9ielPxqFry7Y4Tg3j4BfjPocfJ3TbsRtXOAYXYmRuAQ=
 github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
@@ -61,7 +59,6 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
 github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
 github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
 github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
diff --git a/gomod2nix.toml b/gomod2nix.toml
index 047d995..12e719c 100644
--- a/gomod2nix.toml
+++ b/gomod2nix.toml
@@ -4,9 +4,6 @@ schema = 3
   [mod."github.com/RoaringBitmap/roaring"]
     version = "v1.9.3"
     hash = "sha256-LZfRufkU4UhuEcgxuCPd6divX2KIdcHp1FOt79mQV7Q="
-  [mod."github.com/ardanlabs/conf/v3"]
-    version = "v3.1.7"
-    hash = "sha256-7H53l0JN5Q6hkAgBivVQ8lFd03oNmP1IG8ihzLKm2CQ="
   [mod."github.com/bcicen/jstream"]
     version = "v1.0.1"
     hash = "sha256-mm+/BuIEYYj6XOHCCJLxVMKd1XcBXCiRCWA+aTvr1sE="
diff --git a/import/main.go b/import/main.go
index d9c3bf5..76ebdcf 100644
--- a/import/main.go
+++ b/import/main.go
@@ -1,125 +1,29 @@
 package main
 
 import (
-	"context"
-	"errors"
+	"flag"
 	"log"
 	"log/slog"
-	"os"
-	"os/exec"
-	"path"
 	"searchix/internal/config"
 	"searchix/internal/importer"
-	"searchix/internal/search"
-	"strings"
-	"time"
-
-	"github.com/ardanlabs/conf/v3"
 )
 
-type Config struct {
-	ConfigFile string        `conf:"short:c"`
-	LogLevel   slog.Level    `conf:"default:INFO"`
-	Timeout    time.Duration `conf:"default:30m,help:maximum time to wait for all fetchers and importers combined"`
-	Replace    bool          `conf:"default:false,help:whether to remove existing database, if exists"`
-}
+var (
+	replace    = flag.Bool("replace", false, "whether to replace existing database, if it exists")
+	configFile = flag.String("config", "config.toml", "config file to use")
+)
 
 func main() {
-	if _, found := os.LookupEnv("DEBUG"); found {
-		slog.SetLogLoggerLevel(slog.LevelDebug)
-	}
-	var runtimeConfig Config
-	help, err := conf.Parse("", &runtimeConfig)
-	if err != nil {
-		if errors.Is(err, conf.ErrHelpWanted) {
-			log.Println(help)
-			os.Exit(1)
-		}
-		log.Panicf("parsing runtime configuration: %v", err)
-	}
-	slog.SetLogLoggerLevel(runtimeConfig.LogLevel)
+	flag.Parse()
 
-	cfg, err := config.GetConfig(runtimeConfig.ConfigFile)
+	cfg, err := config.GetConfig(*configFile)
 	if err != nil {
 		log.Fatal(err)
 	}
+	slog.SetLogLoggerLevel(cfg.LogLevel)
 
-	if len(cfg.Sources) == 0 {
-		slog.Info("No sources enabled")
-
-		return
-	}
-
-	indexer, err := search.NewIndexer(cfg.DataPath, runtimeConfig.Replace)
+	err = importer.Start(cfg, *replace)
 	if err != nil {
-		log.Fatalf("Failed to create indexer: %v", err)
-	}
-
-	ctx, cancel := context.WithTimeout(context.Background(), runtimeConfig.Timeout)
-	defer cancel()
-
-	var imp importer.Importer
-	var hadErrors bool
-	for name, source := range cfg.Sources {
-		logger := slog.With("name", name, "importer", source.Type.String())
-		logger.Debug("starting importer")
-
-		importerDataPath := path.Join(cfg.DataPath, "sources", source.Channel)
-
-		switch source.Type {
-		case importer.ChannelNixpkgs:
-			imp = importer.NewNixpkgsChannelImporter(source, importerDataPath, logger)
-		case importer.Channel:
-			imp = importer.NewChannelImporter(source, importerDataPath, logger)
-		default:
-			log.Printf("unsupported importer type %s", source.Type.String())
-
-			continue
-		}
-
-		updated, err := imp.FetchIfNeeded(ctx)
-
-		if err != nil {
-			var exerr *exec.ExitError
-			if errors.As(err, &exerr) {
-				lines := strings.Split(strings.TrimSpace(string(exerr.Stderr)), "\n")
-				for _, line := range lines {
-					logger.Warn("importer fetch failed", "stderr", line, "status", exerr.ExitCode())
-				}
-			} else {
-				logger.Warn("importer fetch failed", "error", err)
-			}
-			hadErrors = true
-
-			continue
-		}
-		logger.Info("importer fetch succeeded", "updated", updated)
-
-		if updated || runtimeConfig.Replace {
-			hadWarnings, err := imp.Import(ctx, indexer)
-
-			if err != nil {
-				msg := err.Error()
-				for _, line := range strings.Split(strings.TrimSpace(msg), "\n") {
-					logger.Error("importer init failed", "error", line)
-				}
-
-				continue
-			}
-			if hadWarnings {
-				logger.Warn("importer succeeded, but with warnings/errors")
-			} else {
-				logger.Info("importer succeeded")
-			}
-		}
-	}
-
-	err = indexer.Close()
-	if err != nil {
-		slog.Error("error closing indexer", "error", err)
-	}
-
-	if hadErrors {
-		defer os.Exit(1)
+		log.Fatal(err)
 	}
 }
diff --git a/internal/config/config.go b/internal/config/config.go
index 340b027..c3a5a90 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -26,75 +26,108 @@ func (u *URL) UnmarshalText(text []byte) (err error) {
 	return nil
 }
 
+func mustURL(in string) (u URL) {
+	var err error
+	u.URL, err = url.Parse(in)
+	if err != nil {
+		panic(errors.Errorf("URL cannot be parsed: %s", in))
+	}
+
+	return u
+}
+
+type Web struct {
+	ContentSecurityPolicy CSP
+	ListenAddress         string
+	Port                  string
+	BaseURL               URL
+	SentryDSN             string
+	Environment           string
+	ExtraBodyHTML         template.HTML
+	Headers               map[string]string
+}
+
+type Importer struct {
+	Sources map[string]*Source
+	Timeout time.Duration
+}
+
 type Config struct {
-	DataPath      string        `toml:"data-path"`
-	CSP           CSP           `toml:"content-security-policy"`
-	ExtraBodyHTML template.HTML `toml:"extra-body-html"`
-	Headers       map[string]string
-	Sources       map[string]*Source
+	DataPath string
+	LogLevel slog.Level
+	Web      *Web
+	Importer *Importer
 }
 
 var defaultConfig = Config{
 	DataPath: "./data",
-	CSP: CSP{
-		DefaultSrc: []string{"'self'"},
-	},
-	Headers: map[string]string{
-		"x-content-type-options": "nosniff",
+	Web: &Web{
+		ListenAddress: "localhost",
+		Port:          "3000",
+		BaseURL:       mustURL("http://localhost:3000"),
+		ContentSecurityPolicy: CSP{
+			DefaultSrc: []string{"'self'"},
+		},
+		Headers: map[string]string{
+			"x-content-type-options": "nosniff",
+		},
 	},
-	Sources: map[string]*Source{
-		"nixos": {
-			Name:          "NixOS",
-			Key:           "nixos",
-			Enable:        true,
-			Type:          Channel,
-			Channel:       "nixpkgs",
-			URL:           "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz",
-			ImportPath:    "nixos/release.nix",
-			Attribute:     "options",
-			OutputPath:    "share/doc/nixos/options.json",
-			FetchTimeout:  5 * time.Minute,
-			ImportTimeout: 15 * time.Minute,
-			Repo: Repository{
-				Type:  "github",
-				Owner: "NixOS",
-				Repo:  "nixpkgs",
+	Importer: &Importer{
+		Timeout: 30 * time.Minute,
+		Sources: map[string]*Source{
+			"nixos": {
+				Name:          "NixOS",
+				Key:           "nixos",
+				Enable:        true,
+				Type:          Channel,
+				Channel:       "nixpkgs",
+				URL:           "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz",
+				ImportPath:    "nixos/release.nix",
+				Attribute:     "options",
+				OutputPath:    "share/doc/nixos/options.json",
+				FetchTimeout:  5 * time.Minute,
+				ImportTimeout: 15 * time.Minute,
+				Repo: Repository{
+					Type:  "github",
+					Owner: "NixOS",
+					Repo:  "nixpkgs",
+				},
 			},
-		},
-		"darwin": {
-			Name:          "Darwin",
-			Key:           "darwin",
-			Enable:        false,
-			Type:          Channel,
-			Channel:       "darwin",
-			URL:           "https://github.com/LnL7/nix-darwin/archive/master.tar.gz",
-			ImportPath:    "release.nix",
-			Attribute:     "options",
-			OutputPath:    "share/doc/darwin/options.json",
-			FetchTimeout:  5 * time.Minute,
-			ImportTimeout: 15 * time.Minute,
-			Repo: Repository{
-				Type:  "github",
-				Owner: "LnL7",
-				Repo:  "nix-darwin",
+			"darwin": {
+				Name:          "Darwin",
+				Key:           "darwin",
+				Enable:        false,
+				Type:          Channel,
+				Channel:       "darwin",
+				URL:           "https://github.com/LnL7/nix-darwin/archive/master.tar.gz",
+				ImportPath:    "release.nix",
+				Attribute:     "options",
+				OutputPath:    "share/doc/darwin/options.json",
+				FetchTimeout:  5 * time.Minute,
+				ImportTimeout: 15 * time.Minute,
+				Repo: Repository{
+					Type:  "github",
+					Owner: "LnL7",
+					Repo:  "nix-darwin",
+				},
 			},
-		},
-		"home-manager": {
-			Name:          "Home Manager",
-			Key:           "home-manager",
-			Enable:        false,
-			Channel:       "home-manager",
-			URL:           "https://github.com/nix-community/home-manager/archive/master.tar.gz",
-			Type:          Channel,
-			ImportPath:    "default.nix",
-			Attribute:     "docs.json",
-			OutputPath:    "share/doc/home-manager/options.json",
-			FetchTimeout:  5 * time.Minute,
-			ImportTimeout: 15 * time.Minute,
-			Repo: Repository{
-				Type:  "github",
-				Owner: "nix-community",
-				Repo:  "home-manager",
+			"home-manager": {
+				Name:          "Home Manager",
+				Key:           "home-manager",
+				Enable:        false,
+				Channel:       "home-manager",
+				URL:           "https://github.com/nix-community/home-manager/archive/master.tar.gz",
+				Type:          Channel,
+				ImportPath:    "default.nix",
+				Attribute:     "docs.json",
+				OutputPath:    "share/doc/home-manager/options.json",
+				FetchTimeout:  5 * time.Minute,
+				ImportTimeout: 15 * time.Minute,
+				Repo: Repository{
+					Type:  "github",
+					Owner: "nix-community",
+					Repo:  "home-manager",
+				},
 			},
 		},
 	},
@@ -122,7 +155,7 @@ func GetConfig(filename string) (*Config, error) {
 		}
 	}
 
-	maps.DeleteFunc(config.Sources, func(_ string, v *Source) bool {
+	maps.DeleteFunc(config.Importer.Sources, func(_ string, v *Source) bool {
 		return !v.Enable
 	})
 
diff --git a/internal/importer/main.go b/internal/importer/main.go
new file mode 100644
index 0000000..a6f15e9
--- /dev/null
+++ b/internal/importer/main.go
@@ -0,0 +1,90 @@
+package importer
+
+import (
+	"context"
+	"errors"
+	"log"
+	"log/slog"
+	"os/exec"
+	"path"
+	"searchix/internal/config"
+	"searchix/internal/search"
+	"strings"
+)
+
+func Start(cfg *config.Config, replace bool) error {
+	if len(cfg.Importer.Sources) == 0 {
+		slog.Info("No sources enabled")
+
+		return nil
+	}
+
+	indexer, err := search.NewIndexer(cfg.DataPath, replace)
+	if err != nil {
+		log.Fatalf("Failed to create indexer: %v", err)
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), cfg.Importer.Timeout)
+	defer cancel()
+
+	var imp Importer
+	for name, source := range cfg.Importer.Sources {
+		logger := slog.With("name", name, "importer", source.Type.String())
+		logger.Debug("starting importer")
+
+		importerDataPath := path.Join(cfg.DataPath, "sources", source.Channel)
+
+		switch source.Type {
+		case config.ChannelNixpkgs:
+			imp = NewNixpkgsChannelImporter(source, importerDataPath, logger)
+		case config.Channel:
+			imp = NewChannelImporter(source, importerDataPath, logger)
+		default:
+			log.Printf("unsupported importer type %s", source.Type.String())
+
+			continue
+		}
+
+		updated, err := imp.FetchIfNeeded(ctx)
+
+		if err != nil {
+			var exerr *exec.ExitError
+			if errors.As(err, &exerr) {
+				lines := strings.Split(strings.TrimSpace(string(exerr.Stderr)), "\n")
+				for _, line := range lines {
+					logger.Warn("importer fetch failed", "stderr", line, "status", exerr.ExitCode())
+				}
+			} else {
+				logger.Warn("importer fetch failed", "error", err)
+			}
+
+			continue
+		}
+		logger.Info("importer fetch succeeded", "updated", updated)
+
+		if updated || replace {
+			hadWarnings, err := imp.Import(ctx, indexer)
+
+			if err != nil {
+				msg := err.Error()
+				for _, line := range strings.Split(strings.TrimSpace(msg), "\n") {
+					logger.Error("importer init failed", "error", line)
+				}
+
+				continue
+			}
+			if hadWarnings {
+				logger.Warn("importer succeeded, but with warnings/errors")
+			} else {
+				logger.Info("importer succeeded")
+			}
+		}
+	}
+
+	err = indexer.Close()
+	if err != nil {
+		slog.Error("error closing indexer", "error", err)
+	}
+
+	return nil
+}
diff --git a/internal/server/headers.go b/internal/server/headers.go
index 0efc384..4fb9efd 100644
--- a/internal/server/headers.go
+++ b/internal/server/headers.go
@@ -7,10 +7,10 @@ import (
 
 func AddHeadersMiddleware(next http.Handler, config *cfg.Config) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		for h, v := range config.Headers {
+		for h, v := range config.Web.Headers {
 			w.Header().Add(h, v)
 		}
-		w.Header().Add("Content-Security-Policy", config.CSP.String())
+		w.Header().Add("Content-Security-Policy", config.Web.ContentSecurityPolicy.String())
 
 		next.ServeHTTP(w, r)
 	})
diff --git a/internal/server/mux.go b/internal/server/mux.go
index ac128ce..9d3b29a 100644
--- a/internal/server/mux.go
+++ b/internal/server/mux.go
@@ -15,8 +15,7 @@ import (
 	"time"
 
 	"searchix/frontend"
-	cfg "searchix/internal/config"
-	"searchix/internal/importer"
+	"searchix/internal/config"
 	"searchix/internal/options"
 	"searchix/internal/search"
 
@@ -28,19 +27,6 @@ import (
 	"github.com/shengyanli1982/law"
 )
 
-var config *cfg.Config
-
-type Config struct {
-	Environment   string     `conf:"default:development"`
-	LiveReload    bool       `conf:"default:false,flag:live"`
-	ListenAddress string     `conf:"default:localhost"`
-	Port          string     `conf:"default:3000,short:p"`
-	BaseURL       cfg.URL    `conf:"default:http://localhost:3000,short:b"`
-	ConfigFile    string     `conf:"short:c"`
-	LogLevel      slog.Level `conf:"default:INFO"`
-	SentryDSN     string
-}
-
 type HTTPError struct {
 	Error   error
 	Message string
@@ -50,8 +36,8 @@ type HTTPError struct {
 const jsSnippet = template.HTML(livereload.JsSnippet) // #nosec G203
 
 type TemplateData struct {
-	Sources       map[string]*importer.Source
-	Source        importer.Source
+	Sources       map[string]*config.Source
+	Source        config.Source
 	Query         string
 	Results       bool
 	SourceResult  *bleve.SearchResult
@@ -67,20 +53,17 @@ type ResultData[T options.NixOption] struct {
 	Next           string
 }
 
-func applyDevModeOverrides(config *cfg.Config) {
-	if len(config.CSP.ScriptSrc) == 0 {
-		config.CSP.ScriptSrc = config.CSP.DefaultSrc
+func applyDevModeOverrides(config *config.Config) {
+	if len(config.Web.ContentSecurityPolicy.ScriptSrc) == 0 {
+		config.Web.ContentSecurityPolicy.ScriptSrc = config.Web.ContentSecurityPolicy.DefaultSrc
 	}
-	config.CSP.ScriptSrc = append(config.CSP.ScriptSrc, "'unsafe-inline'")
+	config.Web.ContentSecurityPolicy.ScriptSrc = append(
+		config.Web.ContentSecurityPolicy.ScriptSrc,
+		"'unsafe-inline'",
+	)
 }
 
-func NewMux(runtimeConfig *Config) (*http.ServeMux, error) {
-	var err error
-	config, err = cfg.GetConfig(runtimeConfig.ConfigFile)
-	if err != nil {
-		return nil, errors.WithMessage(err, "error parsing configuration file")
-	}
-
+func NewMux(config *config.Config, liveReload bool) (*http.ServeMux, error) {
 	slog.Debug("loading index")
 	index, err := search.Open(config.DataPath)
 	slog.Debug("loaded index")
@@ -91,8 +74,8 @@ func NewMux(runtimeConfig *Config) (*http.ServeMux, error) {
 	err = sentry.Init(sentry.ClientOptions{
 		EnableTracing:    true,
 		TracesSampleRate: 1.0,
-		Dsn:              runtimeConfig.SentryDSN,
-		Environment:      runtimeConfig.Environment,
+		Dsn:              config.Web.SentryDSN,
+		Environment:      config.Web.Environment,
 	})
 	if err != nil {
 		return nil, errors.WithMessage(err, "could not set up sentry")
@@ -111,8 +94,8 @@ func NewMux(runtimeConfig *Config) (*http.ServeMux, error) {
 	mux := http.NewServeMux()
 	mux.HandleFunc("/{$}", func(w http.ResponseWriter, _ *http.Request) {
 		indexData := TemplateData{
-			ExtraBodyHTML: config.ExtraBodyHTML,
-			Sources:       config.Sources,
+			ExtraBodyHTML: config.Web.ExtraBodyHTML,
+			Sources:       config.Importer.Sources,
 		}
 		err := templates["index"].ExecuteTemplate(w, "index.gotmpl", indexData)
 		if err != nil {
@@ -124,7 +107,7 @@ func NewMux(runtimeConfig *Config) (*http.ServeMux, error) {
 	mux.HandleFunc("/options/{source}/search", func(w http.ResponseWriter, r *http.Request) {
 		sourceKey := r.PathValue("source")
 
-		source := config.Sources[sourceKey]
+		source := config.Importer.Sources[sourceKey]
 		if source == nil {
 			http.Error(w, "Source not found", http.StatusNotFound)
 
@@ -157,9 +140,9 @@ func NewMux(runtimeConfig *Config) (*http.ServeMux, error) {
 
 			tdata := ResultData[options.NixOption]{
 				TemplateData: TemplateData{
-					ExtraBodyHTML: config.ExtraBodyHTML,
+					ExtraBodyHTML: config.Web.ExtraBodyHTML,
 					Source:        *source,
-					Sources:       config.Sources,
+					Sources:       config.Importer.Sources,
 				},
 				ResultsPerPage: search.ResultsPerPage,
 				Query:          qs,
@@ -216,8 +199,8 @@ func NewMux(runtimeConfig *Config) (*http.ServeMux, error) {
 			}
 
 			err = templates["search"].Execute(w, TemplateData{
-				ExtraBodyHTML: config.ExtraBodyHTML,
-				Sources:       config.Sources,
+				ExtraBodyHTML: config.Web.ExtraBodyHTML,
+				Sources:       config.Importer.Sources,
 				Source:        *source,
 				SourceResult:  sourceResult,
 			})
@@ -231,9 +214,9 @@ func NewMux(runtimeConfig *Config) (*http.ServeMux, error) {
 
 	mux.Handle("/static/", http.FileServer(http.FS(frontend.Files)))
 
-	if runtimeConfig.LiveReload {
+	if liveReload {
 		applyDevModeOverrides(config)
-		config.ExtraBodyHTML = jsSnippet
+		config.Web.ExtraBodyHTML = jsSnippet
 		liveReload := livereload.New()
 		liveReload.Start()
 		top.Handle("/livereload", liveReload)
@@ -258,7 +241,7 @@ func NewMux(runtimeConfig *Config) (*http.ServeMux, error) {
 	}
 
 	var logWriter io.Writer
-	if runtimeConfig.Environment == "production" {
+	if config.Web.Environment == "production" {
 		logWriter = law.NewWriteAsyncer(os.Stdout, nil)
 	} else {
 		logWriter = os.Stdout
@@ -267,7 +250,7 @@ func NewMux(runtimeConfig *Config) (*http.ServeMux, error) {
 		AddHeadersMiddleware(
 			sentryHandler.Handle(
 				wrapHandlerWithLogging(mux, wrappedHandlerOptions{
-					defaultHostname: runtimeConfig.BaseURL.Hostname(),
+					defaultHostname: config.Web.BaseURL.Hostname(),
 					logger:          logWriter,
 				}),
 			),
diff --git a/internal/server/server.go b/internal/server/server.go
index d13d031..77163d3 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -6,6 +6,7 @@ import (
 	"log/slog"
 	"net"
 	"net/http"
+	"searchix/internal/config"
 	"time"
 
 	"github.com/pkg/errors"
@@ -15,12 +16,12 @@ type Server struct {
 	*http.Server
 }
 
-func New(runtimeConfig *Config) (*Server, error) {
-	mux, err := NewMux(runtimeConfig)
+func New(conf *config.Config, liveReload bool) (*Server, error) {
+	mux, err := NewMux(conf, liveReload)
 	if err != nil {
 		return nil, err
 	}
-	listenAddress := net.JoinHostPort(runtimeConfig.ListenAddress, runtimeConfig.Port)
+	listenAddress := net.JoinHostPort(conf.Web.ListenAddress, conf.Web.Port)
 
 	return &Server{
 		&http.Server{
diff --git a/nix/modules/default.nix b/nix/modules/default.nix
index 52a8c93..62cc5c0 100644
--- a/nix/modules/default.nix
+++ b/nix/modules/default.nix
@@ -12,16 +12,6 @@ let
 
   settingsFormat = pkgs.formats.toml { };
 
-  env = {
-    ENVIRONMENT = "production";
-    LISTEN_ADDRESS = cfg.listenAddress;
-    PORT = (toString cfg.port);
-    BASE_URL = cfg.baseUrl;
-    CONFIG_FILE = settingsFormat.generate "searchix-config.toml" cfg.settings;
-    LOG_LEVEL = cfg.logLevel;
-    SENTRY_DSN = cfg.sentryDsn;
-  };
-
   defaultServiceConfig = {
     User = cfg.user;
     Group = cfg.group;
@@ -86,69 +76,84 @@ in
       example = "weekly";
     };
 
-    port = mkOption {
-      type = types.port;
-      description = "Port for searchix to listen on";
-      default = 51313;
-    };
-
-    listenAddress = mkOption {
-      type = types.str;
-      description = "Listen on a specific IP address.";
-      default = "localhost";
-    };
-
-    baseUrl = mkOption {
-      type = types.str;
-      description = "The base URL that searchix will be served on.";
-      default = "http://localhost:3000";
-    };
-
-    sentryDsn = mkOption {
-      type = with types; nullOr str;
-      description = "Optionally enable sentry to track errors.";
-      default = null;
-    };
-
-    logLevel = mkOption {
-      type = with types; enum [ "error" "warn" "info" "debug" ];
-      description = "Only log messages with the given severity or above.";
-      default = "info";
-    };
-
-    importTimeout = mkOption {
-      type = types.str;
-      default = "30m";
-      description = ''
-        Maximum time to wait for all import jobs.
-        May need to be increased based on the number of sources.
-      '';
-    };
-
     settings = mkOption {
       type = types.submodule {
         freeformType = settingsFormat.type;
         options = {
-          data-path = mkOption {
+          dataPath = mkOption {
             type = types.str;
-            description = "Where to store search index and other data, can be relative to homeDir.";
+            description = "Where to store search index and other data.";
             default = "${cfg.homeDir}/data";
           };
-          sources = mkOption {
-            type = with types; attrsOf (submodule (import ./source-options.nix {
-              inherit cfg settingsFormat;
-            }));
-            default = {
-              nixos.enable = true;
-              darwin.enable = false;
-              home-manager.enable = false;
+
+          logLevel = mkOption {
+            type = with types; enum [ "error" "warn" "info" "debug" ];
+            description = "Only log messages with the given severity or above.";
+            default = "info";
+          };
+
+          web = mkOption {
+            type = types.submodule {
+              freeformType = settingsFormat.type;
+              options = {
+                port = mkOption {
+                  type = types.port;
+                  description = "Port for searchix to listen on";
+                  default = 51313;
+                };
+
+                listenAddress = mkOption {
+                  type = types.str;
+                  description = "Listen on a specific IP address.";
+                  default = "localhost";
+                };
+
+                baseURL = mkOption {
+                  type = types.str;
+                  description = "The base URL that searchix will be served on.";
+                  default = "http://localhost:3000";
+                };
+
+                sentryDSN = mkOption {
+                  type = with types; nullOr str;
+                  description = "Optionally enable sentry to track errors.";
+                  default = null;
+                };
+              };
+            };
+          };
+
+          importer = mkOption {
+            type = types.submodule {
+              freeformType = settingsFormat.type;
+
+              importTimeout = mkOption {
+                type = types.str;
+                default = "30m";
+                description = ''
+                  Maximum time to wait for all import jobs.
+                  May need to be increased based on the number of sources.
+                '';
+              };
+
+              sources = mkOption {
+                type = with types;
+                  attrsOf (submodule (import ./source-options.nix {
+                    inherit cfg settingsFormat;
+                  }));
+                default = {
+                  nixos.enable = true;
+                  darwin.enable = false;
+                  home-manager.enable = false;
+                };
+                description = "Declarative specification of options sources for searchix.";
+              };
             };
-            description = "Declarative specification of options sources for searchix.";
           };
         };
       };
       default = { };
-      description = "Configuration for searchix (TODO: publish description).";
+      description = "Configuration for searchix (a web search interface for options in the nix ecosystem).";
     };
   };
 
@@ -156,17 +161,16 @@ in
     systemd.services.searchix-importer = {
       description = "Searchix option importer";
       conflicts = [ "searchix-web.service" ];
-      before = [ "searchix-web.service" ];
       path = with pkgs; [ nix ];
       serviceConfig = defaultServiceConfig // {
-        ExecStart = "${package}/bin/import";
+        ExecStart = "${package}/bin/import --config ${(settingsFormat.generate "searchix-config.toml" cfg.settings)}";
         Type = "oneshot";
 
         RestartSec = 10;
         RestartSteps = 5;
         RestartMaxDelaySec = "5 min";
       };
-      environment = env;
+
       startAt = cfg.dates;
     };
 
@@ -182,9 +186,8 @@ in
       after = [ "searchix-importer.service" ];
       wants = [ "searchix-importer.service" ];
       wantedBy = [ "multi-user.target" ];
-      environment = env;
       serviceConfig = defaultServiceConfig // {
-        ExecStart = "${package}/bin/serve";
+        ExecStart = "${package}/bin/serve --config ${(settingsFormat.generate "searchix-config.toml" cfg.settings)}";
       } // lib.optionalAttrs (cfg.port < 1024) {
         AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
         CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
diff --git a/serve/main.go b/serve/main.go
index 9ba2113..709d340 100644
--- a/serve/main.go
+++ b/serve/main.go
@@ -1,36 +1,36 @@
 package main
 
 import (
-	"fmt"
+	"flag"
 	"log"
 	"log/slog"
 	"os"
 	"os/signal"
 	"sync"
 
+	"searchix/internal/config"
 	"searchix/internal/server"
+)
 
-	"github.com/ardanlabs/conf/v3"
-	"github.com/pkg/errors"
+var (
+	liveReload = flag.Bool("live", false, "whether to enable live reloading (development)")
+	configFile = flag.String("config", "config.toml", "config file to use")
 )
 
 func main() {
-	runtimeConfig := server.Config{}
-	help, err := conf.Parse("", &runtimeConfig)
+	flag.Parse()
+
+	conf, err := config.GetConfig(*configFile)
 	if err != nil {
-		if errors.Is(err, conf.ErrHelpWanted) {
-			fmt.Println(help)
-			os.Exit(1)
-		}
-		log.Panicf("parsing runtime configuration: %v", err)
+		log.Panicf("error parsing configuration file: %v", err)
 	}
-	slog.SetLogLoggerLevel(runtimeConfig.LogLevel)
+	slog.SetLogLoggerLevel(conf.LogLevel)
 	log.SetFlags(log.LstdFlags | log.Lmsgprefix)
 	log.SetPrefix("searchix: ")
 
 	c := make(chan os.Signal, 2)
 	signal.Notify(c, os.Interrupt)
-	sv, err := server.New(&runtimeConfig)
+	sv, err := server.New(conf, *liveReload)
 	if err != nil {
 		log.Fatalf("error setting up server: %v", err)
 	}