diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..76972f9 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,29 @@ +name: Checks + +on: + push: + branches: [main] + pull_request: + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Formatting + run: make fmt-check + + - name: Vet + run: make vet + + - name: Tests + run: make test-race + + - name: Build + run: make build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe69d6b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Anchored, or it also matches cmd/sourceant and the entry point is +# never committed. +/sourceant +coverage.out +coverage.html diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..254fe08 --- /dev/null +++ b/Makefile @@ -0,0 +1,64 @@ +.PHONY: help deps build test test-race test-coverage fmt fmt-check vet lint lint-install clean qa + +BINARY_NAME=sourceant +VERSION?=$(shell cat VERSION 2>/dev/null || echo "dev") +BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S') +GIT_COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +LDFLAGS=-ldflags "-X github.com/sourceant/cli/internal/command.Version=$(VERSION) -X github.com/sourceant/cli/internal/command.BuildTime=$(BUILD_TIME) -X github.com/sourceant/cli/internal/command.GitCommit=$(GIT_COMMIT)" + +help: + @echo "SourceAnt CLI - build commands" + @echo "" + @echo "make deps - Download dependencies" + @echo "make build - Build the CLI binary" + @echo "make test - Run unit tests" + @echo "make test-race - Run unit tests under the race detector" + @echo "make test-coverage - Run tests with a coverage report" + @echo "make fmt - Format code with gofmt" + @echo "make fmt-check - Check gofmt formatting" + @echo "make vet - Run go vet" + @echo "make lint - Run golangci-lint" + @echo "make qa - Run fmt-check, vet, lint, and tests" + @echo "make clean - Clean build artifacts" + +deps: + go mod download + go mod tidy + +build: + go build $(LDFLAGS) -o $(BINARY_NAME) ./cmd/sourceant + +test: + go test ./... + +test-race: + go test -race ./... + +test-coverage: + go test -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + @echo "Coverage report generated: coverage.html" + +fmt: + go fmt ./... + +fmt-check: + @test -z "$$(gofmt -l .)" || (echo "Run gofmt on:" && gofmt -l . && exit 1) + +vet: + go vet ./... + +lint: + @command -v golangci-lint > /dev/null 2>&1 && golangci-lint run ./... || \ + (test -x "$$(go env GOPATH)/bin/golangci-lint" && "$$(go env GOPATH)/bin/golangci-lint" run ./... || \ + (echo "golangci-lint not found. Run 'make lint-install' first." && exit 1)) + +lint-install: + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest + +qa: fmt-check vet lint test + +clean: + rm -f $(BINARY_NAME) + rm -f coverage.out coverage.html + go clean diff --git a/README.md b/README.md new file mode 100644 index 0000000..9fdba93 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# SourceAnt CLI + +The command a person types. It reads the code graph the SourceAnt agent keeps on this machine. + +``` +$ sourceant repos +REPOSITORY PATH +acme/billing /home/you/work/billing + +$ sourceant graph acme/billing +2215 nodes, 2006 links + +KIND COUNT +function 895 +import 854 +class 257 +python 179 + +EDGE COUNT +defines 1152 +imports 854 +``` + +## How the pieces fit + +Three processes, each with one job: + +| | | +|---|---| +| `sourceant` | this CLI, which talks only to the agent | +| `sourceant-agent` | always running: supervises the indexer, keeps the graph current, serves it | +| the SourceAnt core | Python, owns the grammars and the graph | + +The CLI never reaches past the agent. The agent is the process that is always up and the one that knows where the core is listening; going around it would mean learning both. + +## Installing + +```bash +make build +./sourceant install +``` + +`install` puts a core on this machine and writes down which one, so the agent knows what to start. + +Two ways to have it. `--runtime docker` pulls the published image, and is what works today. `--runtime python` builds a virtual environment and pip installs the core, for when the core is published as a package; until then it says so rather than recording something that will not start. + +Both put the index in the same place, `$XDG_DATA_HOME/sourceant`, so it does not matter which one indexed it. The container runs as whoever installed, so what it writes there belongs to them. + +Then start the agent. See [sourceant/agent](https://github.com/sourceant/agent). + +| Variable | Default | Meaning | +|---|---|---| +| `SOURCEANT_AGENT_URL` | `http://127.0.0.1:8930` | The agent to talk to | + +`--agent`, `--timeout` and `--json` override it per command. + +## Commands + +| Command | What it does | +|---|---| +| `sourceant install` | Put a core on this machine | +| `sourceant status` | Whether the agent and the indexer are running | +| `sourceant repos` | Repositories indexed on this machine | +| `sourceant graph ` | What the indexer found in one of them | +| `sourceant ui` | Open the graph in a browser | +| `sourceant version` | What this build is | + +`--json` prints the agent's own answer, for anything that wants to read it rather than look at it. + +## Building + +```bash +make qa # fmt-check, vet, lint, test +make build +``` + +## Licence + +MIT. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/cmd/sourceant/main.go b/cmd/sourceant/main.go new file mode 100644 index 0000000..1bff6d9 --- /dev/null +++ b/cmd/sourceant/main.go @@ -0,0 +1,12 @@ +// Command sourceant is the SourceAnt command line client. +package main + +import ( + "os" + + "github.com/sourceant/cli/internal/command" +) + +func main() { + os.Exit(command.Run(os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4e7d52d --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/sourceant/cli + +go 1.26.1 + +require github.com/spf13/cobra v1.10.2 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6ee3e0 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/agent/client.go b/internal/agent/client.go new file mode 100644 index 0000000..9a6dc0e --- /dev/null +++ b/internal/agent/client.go @@ -0,0 +1,171 @@ +// Package agent talks to the SourceAnt agent running on this machine. +// +// The CLI never reaches past the agent to the Python core. The agent is the +// process that is always up and the one that knows where the core landed; +// going around it would mean learning both. +package agent + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// Status is what the agent says about itself. +type Status struct { + Version string `json:"version"` + CoreURL string `json:"core_url"` + CoreUp bool `json:"core_up"` + CoreStarts int `json:"core_starts"` + LastExit string `json:"last_exit,omitempty"` +} + +// Repository is one repository indexed on this machine. +type Repository struct { + Name string `json:"name"` + Path string `json:"path"` +} + +// Node is one file, import or symbol. +type Node struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + Labels []string `json:"labels"` + Path string `json:"path"` +} + +// Link is a typed edge between two nodes. +type Link struct { + Source string `json:"source"` + Target string `json:"target"` + Type string `json:"type"` +} + +// Graph is one repository's whole scope. +type Graph struct { + Nodes []Node `json:"nodes"` + Links []Link `json:"links"` + Truncated bool `json:"truncated"` +} + +// Error is a non-2xx answer from the agent. +type Error struct { + StatusCode int + Detail string +} + +func (e *Error) Error() string { + if e.Detail == "" { + return fmt.Sprintf("the agent returned %d", e.StatusCode) + } + return e.Detail +} + +// Unreachable says the agent is not running, or not where we looked. +type Unreachable struct { + BaseURL string + Cause error +} + +func (e *Unreachable) Error() string { + return fmt.Sprintf("no agent answering at %s: %v", e.BaseURL, e.Cause) +} + +func (e *Unreachable) Unwrap() error { return e.Cause } + +// Client talks to one agent. +type Client struct { + baseURL string + http *http.Client +} + +// New builds a client for the agent at baseURL. +func New(baseURL string, timeout time.Duration) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + http: &http.Client{Timeout: timeout}, + } +} + +// BaseURL is the agent this client talks to. +func (c *Client) BaseURL() string { return c.baseURL } + +// Status asks the agent how it and the core are doing. +func (c *Client) Status(ctx context.Context) (Status, error) { + return get[Status](ctx, c, "/health", nil) +} + +// Repositories lists what is indexed on this machine. +func (c *Client) Repositories(ctx context.Context) ([]Repository, error) { + return get[[]Repository](ctx, c, "/api/repositories", nil) +} + +// GraphOptions narrows what a drawing covers. +type GraphOptions struct { + PathPrefix string + IncludeTests bool + NodeLimit int +} + +// Graph reads one repository's whole scope. +func (c *Client) Graph(ctx context.Context, repository string, opts GraphOptions) (Graph, error) { + query := url.Values{"repository": {repository}} + if opts.PathPrefix != "" { + query.Set("path_prefix", opts.PathPrefix) + } + if opts.IncludeTests { + query.Set("include_tests", "true") + } + if opts.NodeLimit > 0 { + query.Set("node_limit", strconv.Itoa(opts.NodeLimit)) + } + return get[Graph](ctx, c, "/api/graph", query) +} + +func get[T any](ctx context.Context, c *Client, path string, query url.Values) (T, error) { + var zero T + target := c.baseURL + path + if len(query) > 0 { + target += "?" + query.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return zero, err + } + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return zero, &Unreachable{BaseURL: c.baseURL, Cause: err} + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return zero, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return zero, &Error{StatusCode: resp.StatusCode, Detail: detail(body)} + } + if err := json.Unmarshal(body, &zero); err != nil { + return zero, fmt.Errorf("the agent answered %s with something other than JSON: %w", path, err) + } + return zero, nil +} + +func detail(body []byte) string { + var parsed struct { + Error string `json:"error"` + } + if err := json.Unmarshal(body, &parsed); err == nil && parsed.Error != "" { + return parsed.Error + } + return strings.TrimSpace(string(body)) +} diff --git a/internal/command/install.go b/internal/command/install.go new file mode 100644 index 0000000..feaa57e --- /dev/null +++ b/internal/command/install.go @@ -0,0 +1,57 @@ +package command + +import ( + "fmt" + "os" + + "github.com/sourceant/cli/internal/install" + "github.com/spf13/cobra" +) + +func installCommand() *cobra.Command { + var ( + runtime string + image string + from string + noPull bool + ) + command := &cobra.Command{ + Use: "install", + Short: "Put a SourceAnt core on this machine", + Long: "Two ways to have the core. As a container, which is what exists today. " + + "Or as a Python program, for when the core is published as a package.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + config, err := install.Install(install.Options{ + Runtime: install.Runtime(runtime), + Image: image, + From: from, + Pull: !noPull, + Out: cmd.OutOrStdout(), + }, install.Run) + if err != nil { + return err + } + + path := install.ConfigPath() + if err := os.MkdirAll(config.Core.DataDir, 0o755); err != nil { + return fmt.Errorf("could not make %s, where the index lives: %w", config.Core.DataDir, err) + } + if err := install.Save(path, config); err != nil { + return err + } + + out := cmd.OutOrStdout() + _, _ = fmt.Fprintf(out, "\nInstalled %s\n", config.Core.Describe()) + _, _ = fmt.Fprintf(out, "Index at %s\n", config.Core.DataDir) + _, _ = fmt.Fprintf(out, "Written to %s\n\n", path) + _, _ = fmt.Fprintln(out, "Start it with sourceant-agent, then sourceant ui.") + return nil + }, + } + command.Flags().StringVar(&runtime, "runtime", string(install.Docker), "docker or python") + command.Flags().StringVar(&image, "image", install.DefaultImage, "The container to use, for the docker runtime") + command.Flags().StringVar(&from, "from", install.DefaultPackage, "What pip installs, for the python runtime") + command.Flags().BoolVar(&noPull, "no-pull", false, "Use an image already on this machine") + return command +} diff --git a/internal/command/root.go b/internal/command/root.go new file mode 100644 index 0000000..708abb4 --- /dev/null +++ b/internal/command/root.go @@ -0,0 +1,254 @@ +// Package command is the sourceant command tree. +package command + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "sort" + "time" + + "github.com/sourceant/cli/internal/agent" + "github.com/sourceant/cli/internal/presentation" + "github.com/spf13/cobra" +) + +// Set at build time. See the Makefile. +var ( + Version = "dev" + BuildTime = "unknown" + GitCommit = "unknown" +) + +// EnvAgent points the CLI at an agent somewhere other than the default. +const EnvAgent = "SOURCEANT_AGENT_URL" + +// DefaultAgent is where the agent listens unless it was told otherwise. +const DefaultAgent = "http://127.0.0.1:8930" + +type options struct { + agentURL string + timeout time.Duration + asJSON bool +} + +// Run executes the command tree and returns the process exit code. +func Run(args []string, stdout, stderr io.Writer) int { + opts := &options{} + root := &cobra.Command{ + Use: "sourceant", + Short: "Your code, indexed on this machine", + SilenceUsage: true, + SilenceErrors: true, + } + root.SetOut(stdout) + root.SetErr(stderr) + root.SetArgs(args) + + root.PersistentFlags().StringVar(&opts.agentURL, "agent", agentDefault(), "The agent to talk to") + root.PersistentFlags().DurationVar(&opts.timeout, "timeout", 30*time.Second, "How long to wait for the agent") + root.PersistentFlags().BoolVar(&opts.asJSON, "json", false, "Print the agent's answer as JSON") + + root.AddCommand( + installCommand(), + statusCommand(opts), + reposCommand(opts), + graphCommand(opts), + uiCommand(opts), + versionCommand(), + ) + + if err := root.Execute(); err != nil { + _, _ = fmt.Fprintln(stderr, "sourceant:", message(err)) + return 1 + } + return 0 +} + +// message turns an error into the one line a person needs, which for an agent +// that is not running is what to do about it rather than the socket error. +func message(err error) string { + var unreachable *agent.Unreachable + if errors.As(err, &unreachable) { + return fmt.Sprintf("no agent answering at %s. Start it with sourceant-agent", unreachable.BaseURL) + } + return err.Error() +} + +func agentDefault() string { + if value := os.Getenv(EnvAgent); value != "" { + return value + } + return DefaultAgent +} + +func (o *options) client() *agent.Client { + return agent.New(o.agentURL, o.timeout) +} + +func statusCommand(opts *options) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Whether the agent and the indexer are running", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + status, err := opts.client().Status(cmd.Context()) + if err != nil { + return err + } + if opts.asJSON { + return writeJSON(cmd.OutOrStdout(), status) + } + rows := [][]string{ + {"agent", opts.agentURL, "version " + status.Version}, + {"indexer", status.CoreURL, upOrDown(status.CoreUp)}, + } + if status.CoreStarts > 1 { + rows = append(rows, []string{"", "", presentation.Count(status.CoreStarts, "start", "starts")}) + } + if status.LastExit != "" { + rows = append(rows, []string{"", "", "last exit: " + status.LastExit}) + } + presentation.Table(cmd.OutOrStdout(), nil, rows) + return nil + }, + } +} + +func upOrDown(up bool) string { + if up { + return "answering" + } + return "not answering" +} + +func reposCommand(opts *options) *cobra.Command { + return &cobra.Command{ + Use: "repos", + Aliases: []string{"repositories"}, + Short: "Repositories indexed on this machine", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + repositories, err := opts.client().Repositories(cmd.Context()) + if err != nil { + return err + } + if opts.asJSON { + return writeJSON(cmd.OutOrStdout(), repositories) + } + if len(repositories) == 0 { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Nothing indexed yet. Add a repository with: sourceant repo add ") + return nil + } + rows := make([][]string, 0, len(repositories)) + for _, repository := range repositories { + rows = append(rows, []string{repository.Name, repository.Path}) + } + presentation.Table(cmd.OutOrStdout(), []string{"REPOSITORY", "PATH"}, rows) + return nil + }, + } +} + +func graphCommand(opts *options) *cobra.Command { + var ( + pathPrefix string + includeTests bool + nodeLimit int + ) + command := &cobra.Command{ + Use: "graph ", + Short: "What the indexer found in one repository", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + graph, err := opts.client().Graph(cmd.Context(), args[0], agent.GraphOptions{ + PathPrefix: pathPrefix, + IncludeTests: includeTests, + NodeLimit: nodeLimit, + }) + if err != nil { + return err + } + if opts.asJSON { + return writeJSON(cmd.OutOrStdout(), graph) + } + summarise(cmd.OutOrStdout(), graph) + return nil + }, + } + command.Flags().StringVar(&pathPrefix, "path", "", "Only what sits under this directory") + command.Flags().BoolVar(&includeTests, "tests", false, "Include the test suite") + command.Flags().IntVar(&nodeLimit, "limit", 0, "Stop after this many nodes") + return command +} + +// summarise prints what a terminal can say about a graph it cannot draw. +func summarise(out io.Writer, graph agent.Graph) { + _, _ = fmt.Fprintf(out, "%s, %s\n\n", + presentation.Count(len(graph.Nodes), "node", "nodes"), + presentation.Count(len(graph.Links), "link", "links")) + + presentation.Table(out, []string{"KIND", "COUNT"}, tally(kinds(graph))) + if len(graph.Links) > 0 { + _, _ = fmt.Fprintln(out) + presentation.Table(out, []string{"EDGE", "COUNT"}, tally(edgeTypes(graph))) + } + if graph.Truncated { + _, _ = fmt.Fprintln(out, "\nThis repository is larger than the limit, so this is part of it. Raise --limit to see more.") + } +} + +func kinds(graph agent.Graph) map[string]int { + counts := map[string]int{} + for _, node := range graph.Nodes { + counts[node.Kind]++ + } + return counts +} + +func edgeTypes(graph agent.Graph) map[string]int { + counts := map[string]int{} + for _, link := range graph.Links { + counts[link.Type]++ + } + return counts +} + +// tally orders by count, then by name, so the same graph always prints the same. +func tally(counts map[string]int) [][]string { + names := make([]string, 0, len(counts)) + for name := range counts { + names = append(names, name) + } + sort.Slice(names, func(i, j int) bool { + if counts[names[i]] != counts[names[j]] { + return counts[names[i]] > counts[names[j]] + } + return names[i] < names[j] + }) + rows := make([][]string, 0, len(names)) + for _, name := range names { + rows = append(rows, []string{name, fmt.Sprint(counts[name])}) + } + return rows +} + +func versionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "What this build is", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "sourceant %s (%s, built %s)\n", Version, GitCommit, BuildTime) + return nil + }, + } +} + +func writeJSON(out io.Writer, body any) error { + encoder := json.NewEncoder(out) + encoder.SetIndent("", " ") + return encoder.Encode(body) +} diff --git a/internal/command/root_test.go b/internal/command/root_test.go new file mode 100644 index 0000000..5c46f11 --- /dev/null +++ b/internal/command/root_test.go @@ -0,0 +1,202 @@ +package command + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// The fixtures are answers captured from a running agent, not written here, so +// a change to what it serves fails these rather than passing against our guess. +func fixture(t *testing.T, name string) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("reading fixture: %v", err) + } + return data +} + +type answer struct { + status int + body []byte +} + +// running starts a stand-in agent and returns a run function that drives the +// CLI the way a person does: arguments in, streams and an exit code out. +func running(t *testing.T, answers map[string]answer) func(args ...string) (string, string, int) { + t.Helper() + mux := http.NewServeMux() + for path, reply := range answers { + mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + if reply.status != 0 { + w.WriteHeader(reply.status) + } + _, _ = w.Write(reply.body) + }) + } + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + return func(args ...string) (string, string, int) { + var stdout, stderr bytes.Buffer + code := Run(append([]string{"--agent", server.URL}, args...), &stdout, &stderr) + return stdout.String(), stderr.String(), code + } +} + +func TestStatusSaysWhetherTheIndexerIsAnswering(t *testing.T) { + run := running(t, map[string]answer{ + "/health": {body: fixture(t, "health.json")}, + }) + + stdout, stderr, code := run("status") + + if code != 0 { + t.Fatalf("exited %d: %s", code, stderr) + } + for _, want := range []string{"agent", "indexer", "answering", "0.1.0"} { + if !strings.Contains(stdout, want) { + t.Errorf("%q is missing from:\n%s", want, stdout) + } + } +} + +func TestReposListsWhatIsIndexedHere(t *testing.T) { + run := running(t, map[string]answer{ + "/api/repositories": {body: fixture(t, "repositories.json")}, + }) + + stdout, stderr, code := run("repos") + + if code != 0 { + t.Fatalf("exited %d: %s", code, stderr) + } + if !strings.Contains(stdout, "local/sourceant") { + t.Errorf("the repository is missing from:\n%s", stdout) + } +} + +func TestAnEmptyMachineIsToldWhatToDoNext(t *testing.T) { + run := running(t, map[string]answer{ + "/api/repositories": {body: []byte("[]")}, + }) + + stdout, _, code := run("repos") + + if code != 0 { + t.Fatalf("exited %d", code) + } + if !strings.Contains(stdout, "sourceant repo add") { + t.Errorf("an empty machine was not told how to fill it:\n%s", stdout) + } +} + +func TestGraphCountsWhatTheIndexerFound(t *testing.T) { + run := running(t, map[string]answer{ + "/api/graph": {body: fixture(t, "graph.json")}, + }) + + stdout, stderr, code := run("graph", "local/sourceant") + + if code != 0 { + t.Fatalf("exited %d: %s", code, stderr) + } + for _, want := range []string{"nodes", "links", "KIND", "python", "EDGE", "imports"} { + if !strings.Contains(stdout, want) { + t.Errorf("%q is missing from:\n%s", want, stdout) + } + } +} + +func TestGraphAsJSONIsTheAgentsOwnAnswer(t *testing.T) { + run := running(t, map[string]answer{ + "/api/graph": {body: fixture(t, "graph.json")}, + }) + + stdout, _, code := run("--json", "graph", "local/sourceant") + + if code != 0 { + t.Fatalf("exited %d", code) + } + if !strings.Contains(stdout, `"labels"`) || !strings.Contains(stdout, `"truncated"`) { + t.Errorf("the raw graph was not passed through:\n%s", stdout) + } +} + +func TestAnUnregisteredRepositoryIsReportedInItsOwnWords(t *testing.T) { + run := running(t, map[string]answer{ + "/api/graph": { + status: http.StatusNotFound, + body: []byte(`{"error":"acme/nope is not registered on this machine"}`), + }, + }) + + _, stderr, code := run("graph", "acme/nope") + + if code != 1 { + t.Fatalf("exited %d, want 1", code) + } + if !strings.Contains(stderr, "not registered on this machine") { + t.Errorf("got %q, want the reason the agent gave", stderr) + } +} + +func TestAnAgentThatIsNotRunningSaysHowToStartIt(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := Run([]string{"--agent", "http://127.0.0.1:1", "status"}, &stdout, &stderr) + + if code != 1 { + t.Fatalf("exited %d, want 1", code) + } + if !strings.Contains(stderr.String(), "Start it with sourceant-agent") { + t.Errorf("got %q, want what to do about it", stderr.String()) + } +} + +func TestUIPrintsTheAddressWithoutOpeningAnything(t *testing.T) { + run := running(t, map[string]answer{ + "/health": {body: fixture(t, "health.json")}, + }) + + stdout, stderr, code := run("ui", "--no-open") + + if code != 0 { + t.Fatalf("exited %d: %s", code, stderr) + } + if !strings.Contains(stdout, "http://127.0.0.1:") { + t.Errorf("got %q, want the address to open", stdout) + } +} + +func TestUISaysTheAgentIsDownRatherThanOpeningAnErrorPage(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := Run([]string{"--agent", "http://127.0.0.1:1", "ui", "--no-open"}, &stdout, &stderr) + + if code != 1 { + t.Fatalf("exited %d, want 1", code) + } + if !strings.Contains(stderr.String(), "Start it with sourceant-agent") { + t.Errorf("got %q, want what to do about it", stderr.String()) + } +} + +func TestVersionNeedsNoAgent(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := Run([]string{"--agent", "http://127.0.0.1:1", "version"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("exited %d: %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "sourceant ") { + t.Errorf("got %q, want the build", stdout.String()) + } +} diff --git a/internal/command/testdata/graph.json b/internal/command/testdata/graph.json new file mode 100644 index 0000000..e278c6f --- /dev/null +++ b/internal/command/testdata/graph.json @@ -0,0 +1 @@ +{"nodes":[{"id":"file:src/config/__init__.py","name":"__init__.py","kind":"python","labels":["File"],"path":"src/config/__init__.py"},{"id":"file:src/config/db.py","name":"db.py","kind":"python","labels":["File"],"path":"src/config/db.py"},{"id":"file:src/config/paths.py","name":"paths.py","kind":"python","labels":["File"],"path":"src/config/paths.py"},{"id":"file:src/config/settings.py","name":"settings.py","kind":"python","labels":["File"],"path":"src/config/settings.py"},{"id":"import:src/config/db.py:0","name":"from sqlmodel import create_engine, Session","kind":"import","labels":["Import"],"path":"src/config/db.py"},{"id":"import:src/config/db.py:1","name":"from src.utils.logger import logger","kind":"import","labels":["Import"],"path":"src/config/db.py"},{"id":"import:src/config/db.py:2","name":"from src.config.settings import DATABASE_URL, STATELESS_MODE, DEBUG_MODE","kind":"import","labels":["Import"],"path":"src/config/db.py"},{"id":"import:src/config/paths.py:0","name":"import os","kind":"import","labels":["Import"],"path":"src/config/paths.py"},{"id":"import:src/config/paths.py:1","name":"import secrets","kind":"import","labels":["Import"],"path":"src/config/paths.py"},{"id":"import:src/config/paths.py:2","name":"from pathlib import Path","kind":"import","labels":["Import"],"path":"src/config/paths.py"},{"id":"import:src/config/settings.py:0","name":"import os","kind":"import","labels":["Import"],"path":"src/config/settings.py"},{"id":"import:src/config/settings.py:1","name":"from dotenv import load_dotenv","kind":"import","labels":["Import"],"path":"src/config/settings.py"},{"id":"import:src/config/settings.py:2","name":"from src.config.paths import default_database_url","kind":"import","labels":["Import"],"path":"src/config/settings.py"},{"id":"symbol:src/config/db.py:get_engine:7:0","name":"get_engine","kind":"function","labels":["Function"],"path":"src/config/db.py"},{"id":"symbol:src/config/db.py:get_session:30:1","name":"get_session","kind":"function","labels":["Function"],"path":"src/config/db.py"},{"id":"symbol:src/config/paths.py:data_dir:10:0","name":"data_dir","kind":"function","labels":["Function"],"path":"src/config/paths.py"},{"id":"symbol:src/config/paths.py:default_database_url:26:2","name":"default_database_url","kind":"function","labels":["Function"],"path":"src/config/paths.py"},{"id":"symbol:src/config/paths.py:ensure_data_dir:20:1","name":"ensure_data_dir","kind":"function","labels":["Function"],"path":"src/config/paths.py"},{"id":"symbol:src/config/paths.py:local_jwt_secret:30:3","name":"local_jwt_secret","kind":"function","labels":["Function"],"path":"src/config/paths.py"}],"links":[{"source":"file:src/config/db.py","target":"symbol:src/config/db.py:get_engine:7:0","type":"defines"},{"source":"file:src/config/db.py","target":"symbol:src/config/db.py:get_session:30:1","type":"defines"},{"source":"file:src/config/paths.py","target":"symbol:src/config/paths.py:data_dir:10:0","type":"defines"},{"source":"file:src/config/paths.py","target":"symbol:src/config/paths.py:default_database_url:26:2","type":"defines"},{"source":"file:src/config/paths.py","target":"symbol:src/config/paths.py:ensure_data_dir:20:1","type":"defines"},{"source":"file:src/config/paths.py","target":"symbol:src/config/paths.py:local_jwt_secret:30:3","type":"defines"},{"source":"file:src/config/db.py","target":"import:src/config/db.py:0","type":"imports"},{"source":"file:src/config/db.py","target":"import:src/config/db.py:1","type":"imports"},{"source":"file:src/config/db.py","target":"import:src/config/db.py:2","type":"imports"},{"source":"file:src/config/paths.py","target":"import:src/config/paths.py:0","type":"imports"},{"source":"file:src/config/paths.py","target":"import:src/config/paths.py:1","type":"imports"},{"source":"file:src/config/paths.py","target":"import:src/config/paths.py:2","type":"imports"},{"source":"file:src/config/settings.py","target":"import:src/config/settings.py:0","type":"imports"},{"source":"file:src/config/settings.py","target":"import:src/config/settings.py:1","type":"imports"},{"source":"file:src/config/settings.py","target":"import:src/config/settings.py:2","type":"imports"}],"truncated":false} diff --git a/internal/command/testdata/health.json b/internal/command/testdata/health.json new file mode 100644 index 0000000..1920d83 --- /dev/null +++ b/internal/command/testdata/health.json @@ -0,0 +1 @@ +{"version":"0.1.0","core_url":"http://127.0.0.1:8931","core_up":true,"core_starts":1} diff --git a/internal/command/testdata/repositories.json b/internal/command/testdata/repositories.json new file mode 100644 index 0000000..a42af7e --- /dev/null +++ b/internal/command/testdata/repositories.json @@ -0,0 +1 @@ +[{"name":"local/sourceant","path":"/app"}] diff --git a/internal/command/ui.go b/internal/command/ui.go new file mode 100644 index 0000000..59c9f1f --- /dev/null +++ b/internal/command/ui.go @@ -0,0 +1,50 @@ +package command + +import ( + "fmt" + "os/exec" + "runtime" + + "github.com/spf13/cobra" +) + +func uiCommand(opts *options) *cobra.Command { + var stayPut bool + command := &cobra.Command{ + Use: "ui", + Short: "Open the graph in a browser", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + // Asking the agent first turns "the browser opened on an error + // page" into a line saying the agent is not running. + if _, err := opts.client().Status(cmd.Context()); err != nil { + return err + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), opts.agentURL) + if stayPut { + return nil + } + if err := open(opts.agentURL); err != nil { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Could not open a browser. Follow the address above.") + } + return nil + }, + } + command.Flags().BoolVar(&stayPut, "no-open", false, "Print the address instead of opening it") + return command +} + +// open hands a URL to whatever the desktop uses for one. +func open(target string) error { + var name string + var args []string + switch runtime.GOOS { + case "darwin": + name = "open" + case "windows": + name, args = "rundll32", []string{"url.dll,FileProtocolHandler"} + default: + name = "xdg-open" + } + return exec.Command(name, append(args, target)...).Start() +} diff --git a/internal/install/install.go b/internal/install/install.go new file mode 100644 index 0000000..633ea43 --- /dev/null +++ b/internal/install/install.go @@ -0,0 +1,231 @@ +// Package install puts a SourceAnt core on this machine and writes down which +// one, so the agent can start it. +// +// The file written here is read by the agent. Its field names are the contract +// between the two: change one and change the other, or the agent will start +// nothing. TestTheFileMatchesWhatTheAgentReads pins the shape. +package install + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" +) + +// Runtime is how the core is installed. +type Runtime string + +const ( + Python Runtime = "python" + Docker Runtime = "docker" +) + +// DefaultImage is the published core. +const DefaultImage = "ghcr.io/sourceant/sourceant:latest" + +// DefaultPackage is the core on PyPI. +const DefaultPackage = "sourceant" + +// Core is what was installed. +type Core struct { + Runtime Runtime `json:"runtime"` + Command string `json:"command,omitempty"` + Image string `json:"image,omitempty"` + DataDir string `json:"data_dir,omitempty"` + Mount string `json:"mount,omitempty"` + User string `json:"user,omitempty"` +} + +// Config is the whole file. +type Config struct { + Core Core `json:"core"` +} + +// Home is where SourceAnt keeps what it installed. +func Home() string { + if override := os.Getenv("SOURCEANT_INSTALL_HOME"); override != "" { + return override + } + home, err := os.UserHomeDir() + if err != nil { + return ".sourceant" + } + return filepath.Join(home, ".sourceant") +} + +// ConfigPath is the file the agent reads. +func ConfigPath() string { return filepath.Join(Home(), "config.json") } + +// DataDir is where the index lives, matching what the core picks for itself. +// +// Both runtimes have to agree on it. If they did not, indexing and reading +// would address two different databases and the second would look empty. +func DataDir() string { + if override := os.Getenv("SOURCEANT_HOME"); override != "" { + return override + } + if base := os.Getenv("XDG_DATA_HOME"); base != "" { + return filepath.Join(base, "sourceant") + } + home, err := os.UserHomeDir() + if err != nil { + return ".sourceant-data" + } + return filepath.Join(home, ".local", "share", "sourceant") +} + +// Save writes the runtime for the agent to read. +func Save(path string, config Config) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o644) +} + +// Options describe what to install. +type Options struct { + Runtime Runtime + // Image is the container to pull, for the docker runtime. + Image string + // From is what pip installs, for the python runtime. A package name, a + // version specifier, or a path to a checkout. + From string + // Pull says whether to fetch the image before writing anything down. + Pull bool + // Out receives progress. + Out io.Writer +} + +// Runner runs the commands an install needs. Swapped in tests, because an +// install that really pulls an image is not something to run on every change. +type Runner func(name string, args ...string) ([]byte, error) + +// Run executes a command and returns its combined output. +func Run(name string, args ...string) ([]byte, error) { + return exec.Command(name, args...).CombinedOutput() +} + +// Install puts the core on this machine and returns what to write down. +func Install(opts Options, run Runner) (Config, error) { + switch opts.Runtime { + case Docker: + return installDocker(opts, run) + case Python: + return installPython(opts, run) + default: + return Config{}, fmt.Errorf("runtime %q is neither python nor docker", opts.Runtime) + } +} + +func installDocker(opts Options, run Runner) (Config, error) { + image := opts.Image + if image == "" { + image = DefaultImage + } + if _, err := run("docker", "version", "--format", "{{.Server.Version}}"); err != nil { + return Config{}, errors.New("docker is not running here. Start it, or install the python runtime instead") + } + if opts.Pull { + say(opts.Out, "Pulling %s. This is about 1.4 GB the first time.\n", image) + if output, err := run("docker", "pull", image); err != nil { + return Config{}, fmt.Errorf("could not pull %s: %s", image, trim(output)) + } + } + if _, err := run("docker", "image", "inspect", image); err != nil { + return Config{}, fmt.Errorf("%s is not on this machine. Run without --no-pull to fetch it", image) + } + + home, err := os.UserHomeDir() + if err != nil { + return Config{}, fmt.Errorf("could not find your home directory: %w", err) + } + + return Config{Core: Core{ + Runtime: Docker, + Image: image, + DataDir: DataDir(), + // The indexer reads a repository's files, so the container has to be + // able to see them. Your home is mounted at the path it already has, + // which is what lets one registry of absolute paths mean the same + // thing to either runtime. A repository outside it is not readable + // this way. + Mount: home, + // The image has a user of its own, and where its id differs from this + // person's, everything written into the mounted index would belong to + // somebody who does not exist here. + User: fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()), + }}, nil +} + +func installPython(opts Options, run Runner) (Config, error) { + from := opts.From + if from == "" { + from = DefaultPackage + } + python, err := exec.LookPath("python3") + if err != nil { + return Config{}, errors.New("no python3 on PATH. Install one, or install the docker runtime instead") + } + + venv := filepath.Join(Home(), "runtime") + say(opts.Out, "Building a runtime in %s\n", venv) + if output, err := run(python, "-m", "venv", venv); err != nil { + return Config{}, fmt.Errorf("could not build a runtime: %s", trim(output)) + } + + pip := filepath.Join(venv, "bin", "pip") + say(opts.Out, "Installing %s\n", from) + if output, err := run(pip, "install", "--upgrade", from); err != nil { + return Config{}, fmt.Errorf("could not install %s: %s", from, trim(output)) + } + + command := filepath.Join(venv, "bin", "sourceant") + if _, err := os.Stat(command); err != nil { + return Config{}, fmt.Errorf( + "%s installed without a sourceant command, so there is nothing to start. "+ + "The core is not packaged for PyPI yet; use --runtime docker", from) + } + + return Config{Core: Core{ + Runtime: Python, + Command: command, + DataDir: DataDir(), + }}, nil +} + +// Describe is one line naming what was installed. +func (c Core) Describe() string { + switch c.Runtime { + case Python: + return "python · " + c.Command + case Docker: + return "docker · " + c.Image + " as " + c.User + default: + return string(c.Runtime) + } +} + +func say(out io.Writer, format string, args ...any) { + if out != nil { + _, _ = fmt.Fprintf(out, format, args...) + } +} + +// trim keeps a failure readable: the last of a command's output is where it +// says what went wrong, and the rest is progress nobody needs now. +func trim(output []byte) string { + const keep = 400 + text := string(output) + if len(text) <= keep { + return text + } + return "…" + text[len(text)-keep:] +} diff --git a/internal/install/install_test.go b/internal/install/install_test.go new file mode 100644 index 0000000..173fe57 --- /dev/null +++ b/internal/install/install_test.go @@ -0,0 +1,175 @@ +package install + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// recorder stands in for running real commands, so an install can be driven +// without pulling 1.4 GB on every test run. +type recorder struct { + ran [][]string + fail map[string]error + output []byte +} + +func (r *recorder) run(name string, args ...string) ([]byte, error) { + r.ran = append(r.ran, append([]string{name}, args...)) + if err, found := r.fail[name+" "+strings.Join(args, " ")]; found { + return r.output, err + } + for key, err := range r.fail { + if strings.HasPrefix(name+" "+strings.Join(args, " "), key) { + return r.output, err + } + } + return nil, nil +} + +func (r *recorder) commands() string { + lines := make([]string, 0, len(r.ran)) + for _, command := range r.ran { + lines = append(lines, strings.Join(command, " ")) + } + return strings.Join(lines, "\n") +} + +/* The agent reads this file. These are its field names, spelled out, because + * renaming one here and not there leaves an agent that starts nothing and says + * only that no runtime is installed. */ +func TestTheFileMatchesWhatTheAgentReads(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + err := Save(path, Config{Core: Core{ + Runtime: Docker, + Command: "/somewhere/sourceant", + Image: "ghcr.io/sourceant/sourceant:v1", + DataDir: "/data", + Mount: "/home/someone", + User: "1000:1000", + }}) + if err != nil { + t.Fatalf("saving: %v", err) + } + + var raw map[string]map[string]any + data, _ := os.ReadFile(path) + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("reading back: %v", err) + } + + core, found := raw["core"] + if !found { + t.Fatalf("no core in %s", data) + } + for field, want := range map[string]any{ + "runtime": "docker", + "command": "/somewhere/sourceant", + "image": "ghcr.io/sourceant/sourceant:v1", + "data_dir": "/data", + "mount": "/home/someone", + "user": "1000:1000", + } { + if core[field] != want { + t.Errorf("core.%s is %v, want %v", field, core[field], want) + } + } +} + +func TestInstallingTheImagePullsItAndRecordsWhoIsInstalling(t *testing.T) { + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) + t.Setenv("SOURCEANT_HOME", "/somewhere/data") + run := &recorder{} + + config, err := Install(Options{Runtime: Docker, Image: "img:1", Pull: true}, run.run) + if err != nil { + t.Fatalf("installing: %v", err) + } + + if config.Core.Runtime != Docker || config.Core.Image != "img:1" { + t.Errorf("got %+v, want the image asked for", config.Core) + } + if config.Core.DataDir != "/somewhere/data" { + t.Errorf("got data dir %q, want the core's own", config.Core.DataDir) + } + if config.Core.User != fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()) { + t.Errorf("got user %q, want whoever is installing", config.Core.User) + } + if !strings.Contains(run.commands(), "docker pull img:1") { + t.Errorf("the image was never pulled:\n%s", run.commands()) + } +} + +func TestNoPullUsesAnImageAlreadyHere(t *testing.T) { + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) + run := &recorder{} + + if _, err := Install(Options{Runtime: Docker, Image: "img:1", Pull: false}, run.run); err != nil { + t.Fatalf("installing: %v", err) + } + + if strings.Contains(run.commands(), "docker pull") { + t.Errorf("pulled anyway:\n%s", run.commands()) + } + if !strings.Contains(run.commands(), "docker image inspect img:1") { + t.Errorf("never checked the image is here:\n%s", run.commands()) + } +} + +func TestNoDockerSaysWhatToDoInstead(t *testing.T) { + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) + run := &recorder{fail: map[string]error{"docker version": errors.New("not found")}} + + _, err := Install(Options{Runtime: Docker, Pull: true}, run.run) + + if err == nil { + t.Fatal("installed without docker") + } + if !strings.Contains(err.Error(), "python runtime instead") { + t.Errorf("got %q, want the other way out", err) + } +} + +func TestAnImageThatIsNotHereAndWasNotPulledIsRefused(t *testing.T) { + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) + run := &recorder{fail: map[string]error{"docker image inspect": errors.New("no such image")}} + + _, err := Install(Options{Runtime: Docker, Image: "img:1", Pull: false}, run.run) + + if err == nil { + t.Fatal("recorded an image that is not on this machine") + } + if !strings.Contains(err.Error(), "--no-pull") { + t.Errorf("got %q, want how to fetch it", err) + } +} + +/* The core has no packaging metadata and nothing is on PyPI, so this path + * cannot work yet. What matters is that it says so rather than recording a + * runtime the agent would fail to start. */ +func TestThePythonRuntimeSaysWhyItCannotWorkYet(t *testing.T) { + t.Setenv("SOURCEANT_INSTALL_HOME", t.TempDir()) + run := &recorder{} + + _, err := Install(Options{Runtime: Python, From: "sourceant"}, run.run) + + if err == nil { + t.Fatal("recorded a python runtime with no command behind it") + } + if !strings.Contains(err.Error(), "not packaged for PyPI yet") { + t.Errorf("got %q, want why it cannot work", err) + } + if !strings.Contains(err.Error(), "--runtime docker") { + t.Errorf("got %q, want the way that does work", err) + } +} + +func TestARuntimeThatIsNeitherIsRefused(t *testing.T) { + if _, err := Install(Options{Runtime: "podman"}, (&recorder{}).run); err == nil { + t.Fatal("accepted a runtime that is neither") + } +} diff --git a/internal/presentation/presentation.go b/internal/presentation/presentation.go new file mode 100644 index 0000000..b2e237d --- /dev/null +++ b/internal/presentation/presentation.go @@ -0,0 +1,38 @@ +// Package presentation renders what the CLI has to say. +package presentation + +import ( + "fmt" + "io" + "text/tabwriter" +) + +// Table writes aligned columns, header first. +func Table(out io.Writer, header []string, rows [][]string) { + writer := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + if len(header) > 0 { + writeRow(writer, header) + } + for _, row := range rows { + writeRow(writer, row) + } + _ = writer.Flush() +} + +func writeRow(writer io.Writer, cells []string) { + for i, cell := range cells { + if i > 0 { + _, _ = fmt.Fprint(writer, "\t") + } + _, _ = fmt.Fprint(writer, cell) + } + _, _ = fmt.Fprintln(writer) +} + +// Count renders a number with its noun, singular where that reads better. +func Count(n int, singular, plural string) string { + if n == 1 { + return fmt.Sprintf("%d %s", n, singular) + } + return fmt.Sprintf("%d %s", n, plural) +}