Skip to content
quarry Artifact [72170ba22b]
Login

Artifact [72170ba22b]

Artifact 72170ba22ba93897da2c9e1b606d521f5cb0575c6ebaffc821fb9f7482c6a9ec:


// quarry is a multi-repository forge for zit. See docs/rfcs/.
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/api"
	"craftdesign.group/quarry/internal/events"
	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/ingest"
	"craftdesign.group/quarry/internal/ops"
	"craftdesign.group/quarry/internal/web"
	"craftdesign.group/quarry/internal/zitcli"
)

// version is stamped at build time; every page footer and the API report it
// (0001 Principle 1).
var version = "0.0.0-dev"

func main() {
	if len(os.Args) > 1 {
		switch os.Args[1] {
		case "version":
			fmt.Println("quarry", version)
			return
		case "spec":
			if err := printSpec(os.Stdout); err != nil {
				fmt.Fprintln(os.Stderr, "quarry:", err)
				os.Exit(1)
			}
			return
		case "serve":
			if err := serveCmd(os.Args[2:]); err != nil {
				fmt.Fprintln(os.Stderr, "quarry:", err)
				os.Exit(1)
			}
			return
		case "reindex":
			if err := reindexCmd(os.Args[2:]); err != nil {
				fmt.Fprintln(os.Stderr, "quarry:", err)
				os.Exit(1)
			}
			return
		}
	}
	fmt.Fprintln(os.Stderr, "usage: quarry version|spec|serve|reindex")
	os.Exit(2)
}

// reindexCmd is 0001 §5's full rebuild as a verb: for each --repo, drop
// the derived tables and cursor, then replay the store. Run it with the
// same --repo/--data flags as serve, while serve is stopped (the
// embedded NATS store directory admits one process).
func reindexCmd(args []string) error {
	fs := flag.NewFlagSet("reindex", flag.ContinueOnError)
	repos := repoFlags{}
	fs.Var(repos, "repo", "repository as name=path (repeatable; bare path uses its basename)")
	data := fs.String("data", "", "derived-data directory (default: .quarry beside the first repo)")
	if err := fs.Parse(args); err != nil {
		return err
	}
	if len(repos) == 0 {
		return fmt.Errorf("reindex wants at least one --repo")
	}
	dir := *data
	if dir == "" {
		for _, p := range repos {
			dir = filepath.Join(p, ".quarry")
			break
		}
	}
	ev, err := events.Start(filepath.Join(dir, "nats"))
	if err != nil {
		return fmt.Errorf("%w (is serve still running against this data dir?)", err)
	}
	defer ev.Close()
	for name, path := range repos {
		if err := os.MkdirAll(filepath.Join(dir, name), 0o755); err != nil {
			return err
		}
		ix, err := index.Open(filepath.Join(dir, name, "index.db"))
		if err != nil {
			return err
		}
		n, err := ingest.NewPipeline(zitcli.New(path), ev, ix, name).Reindex()
		ix.Close()
		if err != nil {
			return fmt.Errorf("reindex %s: %w", name, err)
		}
		fmt.Printf("reindexed %s: %d artifacts replayed\n", name, n)
	}
	return nil
}

// repoFlags collects repeatable --repo name=path values (bare paths take
// their directory basename as the repository name).
type repoFlags map[string]string

func (r repoFlags) String() string { return fmt.Sprint(map[string]string(r)) }

func (r repoFlags) Set(v string) error {
	name, path, found := strings.Cut(v, "=")
	if !found {
		path = v
		name = filepath.Base(strings.TrimSuffix(v, "/"))
	}
	if name == "" || path == "" {
		return fmt.Errorf("--repo wants name=path or path, got %q", v)
	}
	if !repoName.MatchString(name) {
		return fmt.Errorf("repository name %q: use letters, digits, dot, dash, underscore", name)
	}
	if _, dup := r[name]; dup {
		return fmt.Errorf("repository %q registered twice", name)
	}
	r[name] = path
	return nil
}

var repoName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)

// serveCmd runs the HTTP surface: the op-generated API plus the HTML
// pages, both consumers of one registry. Each --repo gets its own index
// and watcher pipeline; one embedded NATS carries every store's events
// under zit.evt.<name>.* (0001 §4: a watcher per registered store).
func serveCmd(args []string) error {
	fs := flag.NewFlagSet("serve", flag.ContinueOnError)
	addr := fs.String("addr", "127.0.0.1:8080", "listen address")
	repos := repoFlags{}
	fs.Var(repos, "repo", "repository to serve as name=path (repeatable; bare path uses its basename)")
	data := fs.String("data", "", "derived-data directory (default: .quarry beside the first repo)")
	interval := fs.Duration("sync-interval", 2*time.Second, "store poll interval")
	if err := fs.Parse(args); err != nil {
		return err
	}

	deps := api.Deps{Version: version, Repos: map[string]*api.Repo{}}
	var notifier web.Notifier
	if len(repos) > 0 {
		dir := *data
		if dir == "" {
			for _, p := range repos {
				dir = filepath.Join(p, ".quarry")
				break
			}
		}
		if err := os.MkdirAll(dir, 0o755); err != nil {
			return err
		}
		ev, err := events.Start(filepath.Join(dir, "nats"))
		if err != nil {
			return err
		}
		defer ev.Close()
		notifier = ev

		for name, path := range repos {
			if err := os.MkdirAll(filepath.Join(dir, name), 0o755); err != nil {
				return err
			}
			ix, err := index.Open(filepath.Join(dir, name, "index.db"))
			if err != nil {
				return err
			}
			defer ix.Close()
			cli := zitcli.New(path)
			deps.Repos[name] = &api.Repo{Index: ix, Zit: cli}

			pipe := ingest.NewPipeline(cli, ev, ix, name)
			if n, err := pipe.Sync(); err != nil {
				return fmt.Errorf("initial ingest of %s: %w", name, err)
			} else {
				fmt.Printf("ingested %d artifacts from %s (%s)\n", n, name, path)
			}
			go func(name string, pipe *ingest.Pipeline) {
				for range time.Tick(*interval) {
					if _, err := pipe.Sync(); err != nil {
						log.Printf("ingest %s: %v", name, err)
					}
				}
			}(name, pipe)
		}
	}

	mux, err := buildHandler(deps, notifier)
	if err != nil {
		return err
	}
	fmt.Println("quarry", version, "listening on", *addr)
	return http.ListenAndServe(*addr, mux)
}

// buildHandler wires the two registry consumers onto one mux. notifier
// may be nil (no live pages).
func buildHandler(deps api.Deps, notifier web.Notifier) (http.Handler, error) {
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, deps); err != nil {
		return nil, err
	}
	apiRouter, err := api.Router(r, deps.Version)
	if err != nil {
		return nil, err
	}
	mux := http.NewServeMux()
	mux.Handle("/api/", apiRouter)
	mux.Handle("/", web.Handler(r, notifier))
	return mux, nil
}

// printSpec emits the OpenAPI document generated from the full registry —
// the same registry serve will run, so the spec cannot drift (0001 §9).
func printSpec(w *os.File) error {
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: version}); err != nil {
		return err
	}
	spec, err := ops.Spec(r, "quarry", version)
	if err != nil {
		return err
	}
	enc := json.NewEncoder(w)
	enc.SetIndent("", "  ")
	return enc.Encode(spec)
}