diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-03 17:00:06 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-03 17:00:06 +0300 |
| commit | 4a2d1c60e6ac518d69bbb490db7f28ca791e49b1 (patch) | |
| tree | a4ce5a53924a1759293ed24a1b92899ee5d65021 | |
| parent | 9ce0a4661d6b5dff060b34d3c566f97b76a52996 (diff) | |
add coverage files
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Magefile.go | 200 | ||||
| -rw-r--r-- | docs/coverage.html | 4484 | ||||
| -rw-r--r-- | docs/coverage.out | 5310 |
4 files changed, 9894 insertions, 101 deletions
@@ -2,4 +2,3 @@ /hexai-lsp /.gocache/ /.gomodcache/ -coverage.html diff --git a/Magefile.go b/Magefile.go index e94d89b..2b9cb2b 100644 --- a/Magefile.go +++ b/Magefile.go @@ -4,13 +4,13 @@ package main import ( - "fmt" - "os" - "path/filepath" - "strings" + "fmt" + "os" + "path/filepath" + "strings" - "github.com/magefile/mage/mg" - "github.com/magefile/mage/sh" + "github.com/magefile/mage/mg" + "github.com/magefile/mage/sh" ) // Default target: build both binaries. @@ -18,69 +18,69 @@ var Default = Build // Build builds the Hexai LSP and CLI binaries. func Build() error { - mg.Deps(BuildHexaiLSP, BuildHexaiCLI) - return nil + mg.Deps(BuildHexaiLSP, BuildHexaiCLI) + return nil } // BuildHexaiLSP builds the LSP server binary. func BuildHexaiLSP() error { - return sh.RunV("go", "build", "-o", "hexai-lsp", "cmd/hexai-lsp/main.go") + return sh.RunV("go", "build", "-o", "hexai-lsp", "cmd/hexai-lsp/main.go") } // BuildHexaiCLI builds the CLI binary. func BuildHexaiCLI() error { - return sh.RunV("go", "build", "-o", "hexai", "cmd/hexai/main.go") + return sh.RunV("go", "build", "-o", "hexai", "cmd/hexai/main.go") } // Dev runs tests, vet, lint, then builds with race for both binaries. func Dev() error { - mg.Deps(Test, Vet, Lint) - if err := sh.RunV("go", "build", "-race", "-o", "hexai-lsp", "cmd/hexai-lsp/main.go"); err != nil { - return err - } - return sh.RunV("go", "build", "-race", "-o", "hexai", "cmd/hexai/main.go") + mg.Deps(Test, Vet, Lint) + if err := sh.RunV("go", "build", "-race", "-o", "hexai-lsp", "cmd/hexai-lsp/main.go"); err != nil { + return err + } + return sh.RunV("go", "build", "-race", "-o", "hexai", "cmd/hexai/main.go") } // Run launches the LSP server via go run (useful during development). func Run() error { - mg.Deps(Dev) - return sh.RunV("go", "run", "cmd/hexai-lsp/main.go") + mg.Deps(Dev) + return sh.RunV("go", "run", "cmd/hexai-lsp/main.go") } // RunCLI runs the CLI with a small test input. func RunCLI() error { - mg.Deps(Dev) - cmd := "echo 'test' | go run cmd/hexai/main.go" - return sh.RunV("bash", "-lc", cmd) + mg.Deps(Dev) + cmd := "echo 'test' | go run cmd/hexai/main.go" + return sh.RunV("bash", "-lc", cmd) } // Install copies built binaries to GOPATH/bin (defaults to ~/go/bin when GOPATH is unset). func Install() error { - mg.Deps(Build) - gopath := os.Getenv("GOPATH") - if gopath == "" { - home, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("resolve home: %w", err) - } - gopath = filepath.Join(home, "go") - } - bin := filepath.Join(gopath, "bin") - if err := os.MkdirAll(bin, 0o755); err != nil { - return err - } - if err := sh.RunV("cp", "-v", "./hexai-lsp", bin+"/"); err != nil { - return err - } - return sh.RunV("cp", "-v", "./hexai", bin+"/") + mg.Deps(Build) + gopath := os.Getenv("GOPATH") + if gopath == "" { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("resolve home: %w", err) + } + gopath = filepath.Join(home, "go") + } + bin := filepath.Join(gopath, "bin") + if err := os.MkdirAll(bin, 0o755); err != nil { + return err + } + if err := sh.RunV("cp", "-v", "./hexai-lsp", bin+"/"); err != nil { + return err + } + return sh.RunV("cp", "-v", "./hexai", bin+"/") } // Test runs the test suite. func Test() error { - if err := sh.RunV("go", "clean", "-testcache"); err != nil { - return err - } - return sh.RunV("go", "test", "-v", "./...") + if err := sh.RunV("go", "clean", "-testcache"); err != nil { + return err + } + return sh.RunV("go", "test", "-v", "./...") } // Cover generates a unit test coverage report. @@ -88,81 +88,81 @@ func Test() error { // - Prints function coverage summary to stdout // - Writes HTML report to coverage.html func Cover() error { - // Ensure a clean slate - _ = os.Remove("coverage.out") - _ = os.Remove("coverage.html") - if err := sh.RunV("go", "clean", "-testcache"); err != nil { - return err - } - if err := sh.RunV("go", "test", "-covermode=atomic", "-coverprofile=coverage.out", "./..."); err != nil { - return err - } - // Print function-by-function coverage summary - if out, err := sh.Output("go", "tool", "cover", "-func=coverage.out"); err == nil { - fmt.Print(out) - lines := strings.Split(strings.TrimSpace(out), "\n") - for i := len(lines) - 1; i >= 0; i-- { - if strings.HasPrefix(strings.TrimSpace(lines[i]), "total:") { - fmt.Println("\nTotal coverage:", strings.TrimSpace(lines[i])) - break - } - } - } else { - return err - } - // Generate an HTML report for browsers/editors - if err := sh.RunV("go", "tool", "cover", "-html=coverage.out", "-o", "coverage.html"); err != nil { - return err - } - fmt.Println("HTML coverage report written to ./coverage.html") - return nil + // Ensure a clean slate + _ = os.Remove("docs/coverage.out") + _ = os.Remove("docs/coverage.html") + if err := sh.RunV("go", "clean", "-testcache"); err != nil { + return err + } + if err := sh.RunV("go", "test", "-covermode=atomic", "-coverprofile=docs/coverage.out", "./..."); err != nil { + return err + } + // Print function-by-function coverage summary + if out, err := sh.Output("go", "tool", "cover", "-func=docs/coverage.out"); err == nil { + fmt.Print(out) + lines := strings.Split(strings.TrimSpace(out), "\n") + for i := len(lines) - 1; i >= 0; i-- { + if strings.HasPrefix(strings.TrimSpace(lines[i]), "total:") { + fmt.Println("\nTotal coverage:", strings.TrimSpace(lines[i])) + break + } + } + } else { + return err + } + // Generate an HTML report for browsers/editors + if err := sh.RunV("go", "tool", "cover", "-html=docs/coverage.out", "-o", "docs/coverage.html"); err != nil { + return err + } + fmt.Println("HTML coverage report written to docs/coverage.html") + return nil } // CoverAll generates a combined coverage profile across all packages (cross-package coverage). // Instruments all packages during each test run using -coverpkg=./... so that // coverage collected from one package's tests include code executed in others. func CoverAll() error { - _ = os.Remove("coverage.out") - _ = os.Remove("coverage.html") - if err := sh.RunV("go", "clean", "-testcache"); err != nil { - return err - } - if err := sh.RunV("go", "test", "-covermode=atomic", "-coverpkg=./...", "-coverprofile=coverage.out", "./..."); err != nil { - return err - } - if out, err := sh.Output("go", "tool", "cover", "-func=coverage.out"); err == nil { - fmt.Print(out) - lines := strings.Split(strings.TrimSpace(out), "\n") - for i := len(lines) - 1; i >= 0; i-- { - if strings.HasPrefix(strings.TrimSpace(lines[i]), "total:") { - fmt.Println("\nTotal coverage (cross-package):", strings.TrimSpace(lines[i])) - break - } - } - } else { - return err - } - if err := sh.RunV("go", "tool", "cover", "-html=coverage.out", "-o", "coverage.html"); err != nil { - return err - } - fmt.Println("HTML coverage report written to ./coverage.html (cross-package)") - return nil + _ = os.Remove("docs/coverage.out") + _ = os.Remove("docs/coverage.html") + if err := sh.RunV("go", "clean", "-testcache"); err != nil { + return err + } + if err := sh.RunV("go", "test", "-covermode=atomic", "-coverpkg=./...", "-coverprofile=docs/coverage.out", "./..."); err != nil { + return err + } + if out, err := sh.Output("go", "tool", "cover", "-func=docs/coverage.out"); err == nil { + fmt.Print(out) + lines := strings.Split(strings.TrimSpace(out), "\n") + for i := len(lines) - 1; i >= 0; i-- { + if strings.HasPrefix(strings.TrimSpace(lines[i]), "total:") { + fmt.Println("\nTotal coverage (cross-package):", strings.TrimSpace(lines[i])) + break + } + } + } else { + return err + } + if err := sh.RunV("go", "tool", "cover", "-html=docs/coverage.out", "-o", "docs/coverage.html"); err != nil { + return err + } + fmt.Println("HTML coverage report written to docs/coverage.html (cross-package)") + return nil } // Vet runs go vet. func Vet() error { - return sh.RunV("go", "vet", "./...") + return sh.RunV("go", "vet", "./...") } // Lint runs golangci-lint. func Lint() error { - return sh.RunV("golangci-lint", "run") + return sh.RunV("golangci-lint", "run") } // DevInstall installs helpful developer tools. func DevInstall() error { - if err := sh.RunV("go", "install", "golang.org/x/tools/gopls@latest"); err != nil { - return err - } - return sh.RunV("go", "install", "github.com/golangci/golangci-lint/cmd/golangci-lint@latest") + if err := sh.RunV("go", "install", "golang.org/x/tools/gopls@latest"); err != nil { + return err + } + return sh.RunV("go", "install", "github.com/golangci/golangci-lint/cmd/golangci-lint@latest") } diff --git a/docs/coverage.html b/docs/coverage.html new file mode 100644 index 0000000..ac804e2 --- /dev/null +++ b/docs/coverage.html @@ -0,0 +1,4484 @@ + +<!DOCTYPE html> +<html> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> + <title>hexai-lsp: Go Coverage Report</title> + <style> + body { + background: black; + color: rgb(80, 80, 80); + } + body, pre, #legend span { + font-family: Menlo, monospace; + font-weight: bold; + } + #topbar { + background: black; + position: fixed; + top: 0; left: 0; right: 0; + height: 42px; + border-bottom: 1px solid rgb(80, 80, 80); + } + #content { + margin-top: 50px; + } + #nav, #legend { + float: left; + margin-left: 10px; + } + #legend { + margin-top: 12px; + } + #nav { + margin-top: 10px; + } + #legend span { + margin: 0 5px; + } + .cov0 { color: rgb(192, 0, 0) } +.cov1 { color: rgb(128, 128, 128) } +.cov2 { color: rgb(116, 140, 131) } +.cov3 { color: rgb(104, 152, 134) } +.cov4 { color: rgb(92, 164, 137) } +.cov5 { color: rgb(80, 176, 140) } +.cov6 { color: rgb(68, 188, 143) } +.cov7 { color: rgb(56, 200, 146) } +.cov8 { color: rgb(44, 212, 149) } +.cov9 { color: rgb(32, 224, 152) } +.cov10 { color: rgb(20, 236, 155) } + + </style> + </head> + <body> + <div id="topbar"> + <div id="nav"> + <select id="files"> + + <option value="file0">codeberg.org/snonux/hexai/cmd/hexai-lsp/main.go (0.0%)</option> + + <option value="file1">codeberg.org/snonux/hexai/cmd/hexai/main.go (0.0%)</option> + + <option value="file2">codeberg.org/snonux/hexai/internal/appconfig/config.go (32.3%)</option> + + <option value="file3">codeberg.org/snonux/hexai/internal/hexaicli/run.go (63.8%)</option> + + <option value="file4">codeberg.org/snonux/hexai/internal/hexailsp/run.go (92.5%)</option> + + <option value="file5">codeberg.org/snonux/hexai/internal/llm/copilot.go (7.9%)</option> + + <option value="file6">codeberg.org/snonux/hexai/internal/llm/ollama.go (13.9%)</option> + + <option value="file7">codeberg.org/snonux/hexai/internal/llm/openai.go (32.6%)</option> + + <option value="file8">codeberg.org/snonux/hexai/internal/llm/provider.go (37.9%)</option> + + <option value="file9">codeberg.org/snonux/hexai/internal/llm/util.go (0.0%)</option> + + <option value="file10">codeberg.org/snonux/hexai/internal/logging/chatlogger.go (14.3%)</option> + + <option value="file11">codeberg.org/snonux/hexai/internal/logging/logging.go (90.9%)</option> + + <option value="file12">codeberg.org/snonux/hexai/internal/lsp/context.go (71.8%)</option> + + <option value="file13">codeberg.org/snonux/hexai/internal/lsp/document.go (56.3%)</option> + + <option value="file14">codeberg.org/snonux/hexai/internal/lsp/handlers.go (77.8%)</option> + + <option value="file15">codeberg.org/snonux/hexai/internal/lsp/handlers_codeaction.go (22.9%)</option> + + <option value="file16">codeberg.org/snonux/hexai/internal/lsp/handlers_completion.go (69.8%)</option> + + <option value="file17">codeberg.org/snonux/hexai/internal/lsp/handlers_document.go (1.8%)</option> + + <option value="file18">codeberg.org/snonux/hexai/internal/lsp/handlers_execute.go (0.0%)</option> + + <option value="file19">codeberg.org/snonux/hexai/internal/lsp/handlers_init.go (0.0%)</option> + + <option value="file20">codeberg.org/snonux/hexai/internal/lsp/handlers_utils.go (65.3%)</option> + + <option value="file21">codeberg.org/snonux/hexai/internal/lsp/server.go (68.8%)</option> + + <option value="file22">codeberg.org/snonux/hexai/internal/lsp/transport.go (17.1%)</option> + + </select> + </div> + <div id="legend"> + <span>not tracked</span> + + <span class="cov0">no coverage</span> + <span class="cov1">low coverage</span> + <span class="cov2">*</span> + <span class="cov3">*</span> + <span class="cov4">*</span> + <span class="cov5">*</span> + <span class="cov6">*</span> + <span class="cov7">*</span> + <span class="cov8">*</span> + <span class="cov9">*</span> + <span class="cov10">high coverage</span> + + </div> + </div> + <div id="content"> + + <pre class="file" id="file0" style="display: none">// Summary: Hexai LSP entrypoint; parses flags and delegates to internal/hexailsp. +package main + +import ( + "flag" + "log" + "os" + + "codeberg.org/snonux/hexai/internal" + "codeberg.org/snonux/hexai/internal/hexailsp" +) + +func main() <span class="cov0" title="0">{ + logPath := flag.String("log", "/tmp/hexai-lsp.log", "path to log file (optional)") + showVersion := flag.Bool("version", false, "print version and exit") + flag.Parse() + if *showVersion </span><span class="cov0" title="0">{ + log.Println(internal.Version) + return + }</span> + + <span class="cov0" title="0">if err := hexailsp.Run(*logPath, os.Stdin, os.Stdout, os.Stderr); err != nil </span><span class="cov0" title="0">{ + log.Fatalf("server error: %v", err) + }</span> +} +</pre> + + <pre class="file" id="file1" style="display: none">// Summary: Hexai CLI entrypoint; parses flags and delegates to internal/hexaicli. +package main + +import ( + "context" + "flag" + "fmt" + "os" + + "codeberg.org/snonux/hexai/internal" + "codeberg.org/snonux/hexai/internal/hexaicli" +) + +func main() <span class="cov0" title="0">{ + showVersion := flag.Bool("version", false, "print version and exit") + flag.Parse() + if *showVersion </span><span class="cov0" title="0">{ + fmt.Fprintln(os.Stdout, internal.Version) + return + }</span> + + <span class="cov0" title="0">if err := hexaicli.Run(context.Background(), flag.Args(), os.Stdin, os.Stdout, os.Stderr); err != nil </span><span class="cov0" title="0">{ + os.Exit(1) + }</span> +} +</pre> + + <pre class="file" id="file2" style="display: none">// Summary: Application configuration model and loader; reads ~/.config/hexai/config.json and merges defaults. +package appconfig + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "slices" + "strconv" + "strings" +) + +// App holds user-configurable settings read from ~/.config/hexai/config.json. +type App struct { + MaxTokens int `json:"max_tokens"` + ContextMode string `json:"context_mode"` + ContextWindowLines int `json:"context_window_lines"` + MaxContextTokens int `json:"max_context_tokens"` + LogPreviewLimit int `json:"log_preview_limit"` + // Single knob for LSP requests; if set, overrides hardcoded temps in LSP. + CodingTemperature *float64 `json:"coding_temperature"` + // Minimum identifier characters required for manual (TriggerKind=1) invoke + // to proceed without structural triggers. 0 means always allow. + ManualInvokeMinPrefix int `json:"manual_invoke_min_prefix"` + + // Completion debounce in milliseconds. When > 0, the server waits until + // there has been no text change for at least this duration before sending + // an LLM completion request. + CompletionDebounceMs int `json:"completion_debounce_ms"` + // Completion throttle in milliseconds. When > 0, caps the minimum spacing + // between LLM requests (both chat and code-completer paths). + CompletionThrottleMs int `json:"completion_throttle_ms"` + + TriggerCharacters []string `json:"trigger_characters"` + Provider string `json:"provider"` + + // Provider-specific options + OpenAIBaseURL string `json:"openai_base_url"` + OpenAIModel string `json:"openai_model"` + // Default temperature for OpenAI requests (nil means use provider default) + OpenAITemperature *float64 `json:"openai_temperature"` + OllamaBaseURL string `json:"ollama_base_url"` + OllamaModel string `json:"ollama_model"` + // Default temperature for Ollama requests (nil means use provider default) + OllamaTemperature *float64 `json:"ollama_temperature"` + CopilotBaseURL string `json:"copilot_base_url"` + CopilotModel string `json:"copilot_model"` + // Default temperature for Copilot requests (nil means use provider default) + CopilotTemperature *float64 `json:"copilot_temperature"` +} + +// Constructor: defaults for App (kept first among functions) +func newDefaultConfig() App <span class="cov4" title="3">{ + // Coding-friendly default temperature across providers + // Users can override per provider in config.json (including 0.0). + t := 0.2 + return App{ + MaxTokens: 4000, + ContextMode: "always-full", + ContextWindowLines: 120, + MaxContextTokens: 4000, + LogPreviewLimit: 100, + CodingTemperature: &t, + OpenAITemperature: &t, + OllamaTemperature: &t, + CopilotTemperature: &t, + ManualInvokeMinPrefix: 0, + CompletionDebounceMs: 200, + CompletionThrottleMs: 0, + } +}</span> + +// Load reads configuration from a file and merges with defaults. +// It respects the XDG Base Directory Specification. +func Load(logger *log.Logger) App <span class="cov4" title="3">{ + cfg := newDefaultConfig() + if logger == nil </span><span class="cov3" title="2">{ + return cfg // Return defaults if no logger is provided (e.g. in tests) + }</span> + + <span class="cov1" title="1">configPath, err := getConfigPath() + if err != nil </span><span class="cov0" title="0">{ + logger.Printf("%v", err) + // Even if config path cannot be resolved, still allow env overrides below. + }</span> else<span class="cov1" title="1"> { + if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil </span><span class="cov0" title="0">{ + cfg.mergeWith(fileCfg) + }</span> + // When the config file is missing or invalid, we keep defaults and still + // apply any environment overrides below. + } + + // Environment overrides (take precedence over file) + <span class="cov1" title="1">if envCfg := loadFromEnv(logger); envCfg != nil </span><span class="cov0" title="0">{ + cfg.mergeWith(envCfg) + }</span> + <span class="cov1" title="1">return cfg</span> +} + +// Private helpers +func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="cov1" title="1">{ + f, err := os.Open(path) + if err != nil </span><span class="cov1" title="1">{ + if !os.IsNotExist(err) && logger != nil </span><span class="cov0" title="0">{ + logger.Printf("cannot open config file %s: %v", path, err) + }</span> + <span class="cov1" title="1">return nil, err</span> + } + <span class="cov0" title="0">defer f.Close() + + dec := json.NewDecoder(f) + var fileCfg App + if err := dec.Decode(&fileCfg); err != nil </span><span class="cov0" title="0">{ + if logger != nil </span><span class="cov0" title="0">{ + logger.Printf("invalid config file %s: %v", path, err) + }</span> + <span class="cov0" title="0">return nil, err</span> + } + <span class="cov0" title="0">return &fileCfg, nil</span> +} + +func (a *App) mergeWith(other *App) <span class="cov0" title="0">{ + a.mergeBasics(other) + a.mergeProviderFields(other) +}</span> + +// mergeBasics merges general (non-provider) fields. +func (a *App) mergeBasics(other *App) <span class="cov0" title="0">{ + if other.MaxTokens > 0 </span><span class="cov0" title="0">{ + a.MaxTokens = other.MaxTokens + }</span> + <span class="cov0" title="0">if s := strings.TrimSpace(other.ContextMode); s != "" </span><span class="cov0" title="0">{ + a.ContextMode = s + }</span> + <span class="cov0" title="0">if other.ContextWindowLines > 0 </span><span class="cov0" title="0">{ + a.ContextWindowLines = other.ContextWindowLines + }</span> + <span class="cov0" title="0">if other.MaxContextTokens > 0 </span><span class="cov0" title="0">{ + a.MaxContextTokens = other.MaxContextTokens + }</span> + <span class="cov0" title="0">if other.LogPreviewLimit >= 0 </span><span class="cov0" title="0">{ + a.LogPreviewLimit = other.LogPreviewLimit + }</span> + <span class="cov0" title="0">if other.CodingTemperature != nil </span><span class="cov0" title="0">{ // allow explicit 0.0 + a.CodingTemperature = other.CodingTemperature + }</span> + <span class="cov0" title="0">if other.ManualInvokeMinPrefix >= 0 </span><span class="cov0" title="0">{ + a.ManualInvokeMinPrefix = other.ManualInvokeMinPrefix + }</span> + <span class="cov0" title="0">if other.CompletionDebounceMs > 0 </span><span class="cov0" title="0">{ a.CompletionDebounceMs = other.CompletionDebounceMs }</span> + <span class="cov0" title="0">if other.CompletionThrottleMs > 0 </span><span class="cov0" title="0">{ a.CompletionThrottleMs = other.CompletionThrottleMs }</span> + <span class="cov0" title="0">if len(other.TriggerCharacters) > 0 </span><span class="cov0" title="0">{ + a.TriggerCharacters = slices.Clone(other.TriggerCharacters) + }</span> + <span class="cov0" title="0">if s := strings.TrimSpace(other.Provider); s != "" </span><span class="cov0" title="0">{ + a.Provider = s + }</span> +} + +// mergeProviderFields merges per-provider configuration. +func (a *App) mergeProviderFields(other *App) <span class="cov0" title="0">{ + if s := strings.TrimSpace(other.OpenAIBaseURL); s != "" </span><span class="cov0" title="0">{ + a.OpenAIBaseURL = s + }</span> + <span class="cov0" title="0">if s := strings.TrimSpace(other.OpenAIModel); s != "" </span><span class="cov0" title="0">{ + a.OpenAIModel = s + }</span> + <span class="cov0" title="0">if other.OpenAITemperature != nil </span><span class="cov0" title="0">{ // allow explicit 0.0 + a.OpenAITemperature = other.OpenAITemperature + }</span> + <span class="cov0" title="0">if s := strings.TrimSpace(other.OllamaBaseURL); s != "" </span><span class="cov0" title="0">{ + a.OllamaBaseURL = s + }</span> + <span class="cov0" title="0">if s := strings.TrimSpace(other.OllamaModel); s != "" </span><span class="cov0" title="0">{ + a.OllamaModel = s + }</span> + <span class="cov0" title="0">if other.OllamaTemperature != nil </span><span class="cov0" title="0">{ // allow explicit 0.0 + a.OllamaTemperature = other.OllamaTemperature + }</span> + <span class="cov0" title="0">if s := strings.TrimSpace(other.CopilotBaseURL); s != "" </span><span class="cov0" title="0">{ + a.CopilotBaseURL = s + }</span> + <span class="cov0" title="0">if s := strings.TrimSpace(other.CopilotModel); s != "" </span><span class="cov0" title="0">{ + a.CopilotModel = s + }</span> + <span class="cov0" title="0">if other.CopilotTemperature != nil </span><span class="cov0" title="0">{ // allow explicit 0.0 + a.CopilotTemperature = other.CopilotTemperature + }</span> +} + +func getConfigPath() (string, error) <span class="cov1" title="1">{ + var configPath string + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" </span><span class="cov0" title="0">{ + configPath = filepath.Join(xdgConfigHome, "hexai", "config.json") + }</span> else<span class="cov1" title="1"> { + home, err := os.UserHomeDir() + if err != nil </span><span class="cov0" title="0">{ + return "", fmt.Errorf("cannot find user home directory: %v", err) + }</span> + <span class="cov1" title="1">configPath = filepath.Join(home, ".config", "hexai", "config.json")</span> + } + <span class="cov1" title="1">return configPath, nil</span> +} + +// --- Environment overrides --- + +// loadFromEnv constructs an App containing only fields set via HEXAI_* env vars. +// These values should take precedence over file config when merged. +func loadFromEnv(logger *log.Logger) *App <span class="cov1" title="1">{ + var out App + var any bool + + // helpers + getenv := func(k string) string </span><span class="cov10" title="20">{ return strings.TrimSpace(os.Getenv(k)) }</span> + <span class="cov1" title="1">parseInt := func(k string) (int, bool) </span><span class="cov6" title="7">{ + v := getenv(k) + if v == "" </span><span class="cov6" title="7">{ return 0, false }</span> + <span class="cov0" title="0">n, err := strconv.Atoi(v) + if err != nil </span><span class="cov0" title="0">{ if logger != nil </span><span class="cov0" title="0">{ logger.Printf("invalid %s: %v", k, err) }</span> ; <span class="cov0" title="0">return 0, false</span> } + <span class="cov0" title="0">return n, true</span> + } + <span class="cov1" title="1">parseFloatPtr := func(k string) (*float64, bool) </span><span class="cov5" title="4">{ + v := getenv(k) + if v == "" </span><span class="cov5" title="4">{ return nil, false }</span> + <span class="cov0" title="0">f, err := strconv.ParseFloat(v, 64) + if err != nil </span><span class="cov0" title="0">{ + if logger != nil </span><span class="cov0" title="0">{ logger.Printf("invalid %s: %v", k, err) }</span> + <span class="cov0" title="0">return nil, false</span> + } + <span class="cov0" title="0">return &f, true</span> + } + + <span class="cov1" title="1">if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok </span><span class="cov0" title="0">{ + out.MaxTokens = n; any = true + }</span> + <span class="cov1" title="1">if s := getenv("HEXAI_CONTEXT_MODE"); s != "" </span><span class="cov0" title="0">{ + out.ContextMode = s; any = true + }</span> + <span class="cov1" title="1">if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok </span><span class="cov0" title="0">{ + out.ContextWindowLines = n; any = true + }</span> + <span class="cov1" title="1">if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok </span><span class="cov0" title="0">{ + out.MaxContextTokens = n; any = true + }</span> + <span class="cov1" title="1">if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok </span><span class="cov0" title="0">{ + out.LogPreviewLimit = n; any = true + }</span> + <span class="cov1" title="1">if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok </span><span class="cov0" title="0">{ + out.ManualInvokeMinPrefix = n; any = true + }</span> + <span class="cov1" title="1">if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok </span><span class="cov0" title="0">{ + out.CompletionDebounceMs = n; any = true + }</span> + <span class="cov1" title="1">if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok </span><span class="cov0" title="0">{ + out.CompletionThrottleMs = n; any = true + }</span> + <span class="cov1" title="1">if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok </span><span class="cov0" title="0">{ + out.CodingTemperature = f; any = true + }</span> + <span class="cov1" title="1">if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" </span><span class="cov0" title="0">{ + parts := strings.Split(s, ",") + out.TriggerCharacters = nil + for _, p := range parts </span><span class="cov0" title="0">{ + if t := strings.TrimSpace(p); t != "" </span><span class="cov0" title="0">{ + out.TriggerCharacters = append(out.TriggerCharacters, t) + }</span> + } + <span class="cov0" title="0">any = true</span> + } + <span class="cov1" title="1">if s := getenv("HEXAI_PROVIDER"); s != "" </span><span class="cov0" title="0">{ + out.Provider = s; any = true + }</span> + + // Provider-specific + <span class="cov1" title="1">if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" </span><span class="cov0" title="0">{ out.OpenAIBaseURL = s; any = true }</span> + <span class="cov1" title="1">if s := getenv("HEXAI_OPENAI_MODEL"); s != "" </span><span class="cov0" title="0">{ out.OpenAIModel = s; any = true }</span> + <span class="cov1" title="1">if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok </span><span class="cov0" title="0">{ out.OpenAITemperature = f; any = true }</span> + + <span class="cov1" title="1">if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" </span><span class="cov0" title="0">{ out.OllamaBaseURL = s; any = true }</span> + <span class="cov1" title="1">if s := getenv("HEXAI_OLLAMA_MODEL"); s != "" </span><span class="cov0" title="0">{ out.OllamaModel = s; any = true }</span> + <span class="cov1" title="1">if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok </span><span class="cov0" title="0">{ out.OllamaTemperature = f; any = true }</span> + + <span class="cov1" title="1">if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" </span><span class="cov0" title="0">{ out.CopilotBaseURL = s; any = true }</span> + <span class="cov1" title="1">if s := getenv("HEXAI_COPILOT_MODEL"); s != "" </span><span class="cov0" title="0">{ out.CopilotModel = s; any = true }</span> + <span class="cov1" title="1">if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok </span><span class="cov0" title="0">{ out.CopilotTemperature = f; any = true }</span> + + <span class="cov1" title="1">if !any </span><span class="cov1" title="1">{ + return nil + }</span> + <span class="cov0" title="0">return &out</span> +} +</pre> + + <pre class="file" id="file3" style="display: none">// Summary: Hexai CLI runner; reads input, creates an LLM client, builds messages, +// streams or collects the model output, and prints a short summary to stderr. +package hexaicli + +import ( + "bufio" + "context" + "fmt" + "io" + "log" + "os" + "strings" + "time" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" +) + +// Run executes the Hexai CLI behavior given arguments and I/O streams. +// It assumes flags have already been parsed by the caller. +func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error <span class="cov0" title="0">{ + // Load configuration with a logger so file-based config is respected. + logger := log.New(stderr, "hexai ", log.LstdFlags|log.Lmsgprefix) + cfg := appconfig.Load(logger) + client, err := newClientFromConfig(cfg) + if err != nil </span><span class="cov0" title="0">{ + fmt.Fprintf(stderr, logging.AnsiBase+"hexai: LLM disabled: %v"+logging.AnsiReset+"\n", err) + return err + }</span> + + <span class="cov0" title="0">return RunWithClient(ctx, args, stdin, stdout, stderr, client)</span> +} + +// RunWithClient executes the CLI flow using an already-constructed client. +// Useful for testing and embedding. +func RunWithClient(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer, client llm.Client) error <span class="cov6" title="3">{ + input, err := readInput(stdin, args) + if err != nil </span><span class="cov0" title="0">{ + fmt.Fprintln(stderr, logging.AnsiBase+err.Error()+logging.AnsiReset) + return err + }</span> + <span class="cov6" title="3">printProviderInfo(stderr, client) + msgs := buildMessages(input) + if err := runChat(ctx, client, msgs, input, stdout, stderr); err != nil </span><span class="cov0" title="0">{ + fmt.Fprintf(stderr, logging.AnsiBase+"hexai: error: %v"+logging.AnsiReset+"\n", err) + return err + }</span> + <span class="cov6" title="3">return nil</span> +} + +// readInput reads from stdin and args, then combines them per CLI rules. +func readInput(stdin io.Reader, args []string) (string, error) <span class="cov10" title="7">{ + var stdinData string + if fi, err := os.Stdin.Stat(); err == nil && (fi.Mode()&os.ModeCharDevice) == 0 </span><span class="cov10" title="7">{ + b, _ := io.ReadAll(bufio.NewReader(stdin)) + stdinData = strings.TrimSpace(string(b)) + }</span> + <span class="cov10" title="7">argData := strings.TrimSpace(strings.Join(args, " ")) + switch </span>{ + case stdinData != "" && argData != "":<span class="cov4" title="2"> + return fmt.Sprintf("%s:\n\n%s", argData, stdinData), nil</span> + case stdinData != "":<span class="cov1" title="1"> + return stdinData, nil</span> + case argData != "":<span class="cov6" title="3"> + return argData, nil</span> + default:<span class="cov1" title="1"> + return "", fmt.Errorf("hexai: no input provided; pass text as an argument or via stdin")</span> + } +} + +// newClientFromConfig builds an LLM client from the app config and env keys. +func newClientFromConfig(cfg appconfig.App) (llm.Client, error) <span class="cov0" title="0">{ + llmCfg := llm.Config{ + Provider: cfg.Provider, + OpenAIBaseURL: cfg.OpenAIBaseURL, + OpenAIModel: cfg.OpenAIModel, + OpenAITemperature: cfg.OpenAITemperature, + OllamaBaseURL: cfg.OllamaBaseURL, + OllamaModel: cfg.OllamaModel, + OllamaTemperature: cfg.OllamaTemperature, + CopilotBaseURL: cfg.CopilotBaseURL, + CopilotModel: cfg.CopilotModel, + CopilotTemperature: cfg.CopilotTemperature, + } + // Prefer HEXAI_OPENAI_API_KEY; fall back to OPENAI_API_KEY + oaKey := os.Getenv("HEXAI_OPENAI_API_KEY") + if strings.TrimSpace(oaKey) == "" </span><span class="cov0" title="0">{ + oaKey = os.Getenv("OPENAI_API_KEY") + }</span> + // Prefer HEXAI_COPILOT_API_KEY; fall back to COPILOT_API_KEY + <span class="cov0" title="0">cpKey := os.Getenv("HEXAI_COPILOT_API_KEY") + if strings.TrimSpace(cpKey) == "" </span><span class="cov0" title="0">{ + cpKey = os.Getenv("COPILOT_API_KEY") + }</span> + <span class="cov0" title="0">return llm.NewFromConfig(llmCfg, oaKey, cpKey)</span> +} + +// buildMessages creates system and user messages based on input content. +func buildMessages(input string) []llm.Message <span class="cov8" title="5">{ + lower := strings.ToLower(input) + system := "You are Hexai CLI. Default to very short, concise answers. If the user asks for commands, output only the commands (one per line) with no commentary or explanation. Only when the word 'explain' appears in the prompt, produce a verbose explanation. |
