diff --git a/internal/slm/download.go b/internal/slm/download.go new file mode 100644 index 0000000..27d6051 --- /dev/null +++ b/internal/slm/download.go @@ -0,0 +1,86 @@ +package slm + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" +) + +// download fetches url to dst and returns the hex SHA256 and byte count of the download. +// Partial files are deleted on any failure. +// progress receives (downloaded, total) bytes; total is -1 if the server doesn't report Content-Length. +func download(ctx context.Context, url, dst string, progress func(downloaded, total int64)) (sha256hex string, size int64, err error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", 0, fmt.Errorf("slm: download: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", 0, fmt.Errorf("slm: download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", 0, fmt.Errorf("slm: download: unexpected status %s", resp.Status) + } + + f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0700) + if err != nil { + return "", 0, fmt.Errorf("slm: download: create file: %w", err) + } + + ok := false + defer func() { + f.Close() + if !ok { + os.Remove(dst) + } + }() + + h := sha256.New() + pr := &progressReader{r: resp.Body, total: resp.ContentLength, fn: progress} + n, err := io.Copy(io.MultiWriter(f, h), pr) + if err != nil { + return "", 0, fmt.Errorf("slm: download: write: %w", err) + } + + ok = true + return hex.EncodeToString(h.Sum(nil)), n, nil +} + +// hashFile computes the SHA256 of the file at path and returns the hex digest and size. +func hashFile(path string) (sha256hex string, size int64, err error) { + f, err := os.Open(path) + if err != nil { + return "", 0, err + } + defer f.Close() + + h := sha256.New() + n, err := io.Copy(h, f) + if err != nil { + return "", 0, err + } + return hex.EncodeToString(h.Sum(nil)), n, nil +} + +type progressReader struct { + r io.Reader + total int64 + read int64 + fn func(downloaded, total int64) +} + +func (p *progressReader) Read(b []byte) (int, error) { + n, err := p.r.Read(b) + p.read += int64(n) + if p.fn != nil { + p.fn(p.read, p.total) + } + return n, err +} diff --git a/internal/slm/download_test.go b/internal/slm/download_test.go new file mode 100644 index 0000000..3e0cf86 --- /dev/null +++ b/internal/slm/download_test.go @@ -0,0 +1,130 @@ +package slm + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestDownload_Success(t *testing.T) { + content := []byte("hello llamafile") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write(content) + })) + defer srv.Close() + + dst := filepath.Join(t.TempDir(), "model.llamafile") + var lastDownloaded, lastTotal int64 + sha256hex, size, err := download(context.Background(), srv.URL, dst, func(d, total int64) { + lastDownloaded, lastTotal = d, total + }) + if err != nil { + t.Fatalf("download: %v", err) + } + + h := sha256.Sum256(content) + want := hex.EncodeToString(h[:]) + if sha256hex != want { + t.Errorf("SHA256: got %s, want %s", sha256hex, want) + } + if size != int64(len(content)) { + t.Errorf("size: got %d, want %d", size, len(content)) + } + if lastDownloaded != int64(len(content)) { + t.Errorf("progress last downloaded: got %d, want %d", lastDownloaded, len(content)) + } + _ = lastTotal // may be -1 if server doesn't set Content-Length + + data, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("read dst: %v", err) + } + if string(data) != string(content) { + t.Errorf("file content mismatch") + } +} + +func TestDownload_NonOKStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + defer srv.Close() + + dst := filepath.Join(t.TempDir(), "model.llamafile") + _, _, err := download(context.Background(), srv.URL, dst, nil) + if err == nil { + t.Fatal("want error for 404, got nil") + } + // Partial file must be cleaned up. + if _, statErr := os.Stat(dst); !os.IsNotExist(statErr) { + t.Error("partial file should have been deleted on failure") + } +} + +func TestDownload_ContextCancel(t *testing.T) { + done := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "no flusher", 500) + return + } + w.WriteHeader(http.StatusOK) + flusher.Flush() + <-done // block until test cancels + })) + defer srv.Close() + defer close(done) + + dst := filepath.Join(t.TempDir(), "model.llamafile") + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + _, _, err := download(ctx, srv.URL, dst, nil) + if err == nil { + t.Fatal("want error on cancelled context, got nil") + } +} + +func TestDownload_NilProgress(t *testing.T) { + content := []byte("data") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write(content) + })) + defer srv.Close() + + dst := filepath.Join(t.TempDir(), "model.llamafile") + _, _, err := download(context.Background(), srv.URL, dst, nil) + if err != nil { + t.Fatalf("download with nil progress: %v", err) + } +} + +func TestHashFile(t *testing.T) { + content := []byte("test content for hashing") + f, err := os.CreateTemp(t.TempDir(), "hash*") + if err != nil { + t.Fatal(err) + } + f.Write(content) + f.Close() + + h := sha256.Sum256(content) + want := hex.EncodeToString(h[:]) + + got, size, err := hashFile(f.Name()) + if err != nil { + t.Fatalf("hashFile: %v", err) + } + if got != want { + t.Errorf("SHA256: got %s, want %s", got, want) + } + if size != int64(len(content)) { + t.Errorf("size: got %d, want %d", size, len(content)) + } +} diff --git a/internal/slm/manager.go b/internal/slm/manager.go new file mode 100644 index 0000000..091de66 --- /dev/null +++ b/internal/slm/manager.go @@ -0,0 +1,243 @@ +package slm + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +const pidFile = "llamafile.pid" + +// Status describes the setup state of the SLM. +type Status int + +const ( + StatusNotSetUp Status = iota // no manifest on disk + StatusReady // manifest + binary file both exist + StatusMissing // manifest exists but binary file is gone +) + +func (s Status) String() string { + switch s { + case StatusNotSetUp: + return "not set up" + case StatusReady: + return "ready" + case StatusMissing: + return "file missing" + default: + return "unknown" + } +} + +// Config holds Manager configuration. +type Config struct { + DataDir string // XDG data home / gnoma / slm; must be set + ModelURL string // required for Setup +} + +// Manager controls the llamafile lifecycle. +type Manager struct { + cfg Config + process *os.Process + port int + logger *slog.Logger +} + +// New creates a Manager. DataDir must be non-empty. +func New(cfg Config, logger *slog.Logger) *Manager { + if logger == nil { + logger = slog.Default() + } + return &Manager{cfg: cfg, logger: logger} +} + +// IsSetUp returns true when Status() == StatusReady. +func (m *Manager) IsSetUp() bool { + return m.Status() == StatusReady +} + +// Status returns the current setup state by inspecting the manifest and filesystem. +func (m *Manager) Status() Status { + mf, err := readManifest(m.cfg.DataDir) + if err != nil { + return StatusNotSetUp + } + if _, err := os.Stat(mf.FilePath); err != nil { + return StatusMissing + } + return StatusReady +} + +// Setup downloads the llamafile from ModelURL, verifies the hash, and writes the manifest. +// progress receives (downloaded, total) byte counts; may be nil. +func (m *Manager) Setup(ctx context.Context, progress func(downloaded, total int64)) error { + if m.cfg.ModelURL == "" { + return fmt.Errorf("slm: ModelURL is required") + } + + if err := os.MkdirAll(m.cfg.DataDir, 0700); err != nil { + return fmt.Errorf("slm: create data dir: %w", err) + } + + name := filepath.Base(m.cfg.ModelURL) + if name == "" || name == "." { + name = "llamafile" + } + dst := filepath.Join(m.cfg.DataDir, name) + + m.logger.Info("downloading llamafile", "url", m.cfg.ModelURL, "dst", dst) + + sha256hex, size, err := download(ctx, m.cfg.ModelURL, dst, progress) + if err != nil { + return err + } + + mf := &Manifest{ + ModelURL: m.cfg.ModelURL, + FilePath: dst, + SHA256: sha256hex, + Size: size, + SetupAt: time.Now().UTC(), + } + return writeManifest(m.cfg.DataDir, mf) +} + +// Start launches the llamafile subprocess and returns its base URL. +// Reaps a stale PID file from a previous run if present. +func (m *Manager) Start(ctx context.Context) (string, error) { + mf, err := readManifest(m.cfg.DataDir) + if err != nil { + return "", fmt.Errorf("slm: not set up: %w", err) + } + if _, err := os.Stat(mf.FilePath); err != nil { + return "", fmt.Errorf("slm: llamafile missing at %s", mf.FilePath) + } + + m.reapStalePID() + + port, err := freePort() + if err != nil { + return "", fmt.Errorf("slm: find free port: %w", err) + } + + cmd := exec.CommandContext(ctx, mf.FilePath, + "--server", + "--host", "127.0.0.1", + "--port", strconv.Itoa(port), + "--nobrowser", + ) + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("slm: start llamafile: %w", err) + } + + m.process = cmd.Process + m.port = port + + if err := os.WriteFile(m.pidPath(), []byte(strconv.Itoa(cmd.Process.Pid)), 0600); err != nil { + m.logger.Warn("failed to write pid file", "error", err) + } + + baseURL := fmt.Sprintf("http://127.0.0.1:%d", port) + m.logger.Info("llamafile started", "pid", cmd.Process.Pid, "url", baseURL) + + if err := waitHealthy(ctx, baseURL); err != nil { + _ = m.Stop() + return "", err + } + + return baseURL, nil +} + +// Stop terminates the llamafile process and cleans up the PID file. +func (m *Manager) Stop() error { + if m.process == nil { + return nil + } + if err := m.process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { + return fmt.Errorf("slm: kill llamafile: %w", err) + } + m.process = nil + m.port = 0 + _ = os.Remove(m.pidPath()) + return nil +} + +// BaseURL returns the current server base URL, or "" if not running. +func (m *Manager) BaseURL() string { + if m.process == nil || m.port == 0 { + return "" + } + return fmt.Sprintf("http://127.0.0.1:%d", m.port) +} + +func (m *Manager) pidPath() string { + return filepath.Join(m.cfg.DataDir, pidFile) +} + +func (m *Manager) reapStalePID() { + data, err := os.ReadFile(m.pidPath()) + if err != nil { + return + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + _ = os.Remove(m.pidPath()) + return + } + proc, err := os.FindProcess(pid) + if err != nil { + _ = os.Remove(m.pidPath()) + return + } + _ = proc.Kill() + _ = os.Remove(m.pidPath()) + m.logger.Debug("reaped stale llamafile process", "pid", pid) +} + +// freePort binds on :0 to let the OS pick an available port, then releases it. +// There is a small TOCTOU window between release and use, which is acceptable for a local dev tool. +func freePort() (int, error) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + port := l.Addr().(*net.TCPAddr).Port + _ = l.Close() + return port, nil +} + +// waitHealthy polls baseURL/health until it returns 200 or ctx is cancelled. +// Ceiling: 15 seconds (cold model load can take 5–10 s). +func waitHealthy(ctx context.Context, baseURL string) error { + deadline := time.Now().Add(15 * time.Second) + client := &http.Client{Timeout: 2 * time.Second} + + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + resp, err := client.Get(baseURL + "/health") + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + + time.Sleep(200 * time.Millisecond) + } + return fmt.Errorf("slm: health check timed out after 15s") +} diff --git a/internal/slm/manager_test.go b/internal/slm/manager_test.go new file mode 100644 index 0000000..c35e74c --- /dev/null +++ b/internal/slm/manager_test.go @@ -0,0 +1,273 @@ +package slm + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "testing" + "time" +) + +func TestManager_StatusNotSetUp(t *testing.T) { + m := New(Config{DataDir: t.TempDir(), ModelURL: ""}, nil) + if got := m.Status(); got != StatusNotSetUp { + t.Errorf("Status = %v, want StatusNotSetUp", got) + } +} + +func TestManager_IsSetUp_False(t *testing.T) { + m := New(Config{DataDir: t.TempDir()}, nil) + if m.IsSetUp() { + t.Error("IsSetUp should be false before Setup") + } +} + +func TestManager_StatusReady(t *testing.T) { + dir := t.TempDir() + // create the actual file the manifest points to + dst := filepath.Join(dir, "model.llamafile") + if err := os.WriteFile(dst, []byte("binary"), 0700); err != nil { + t.Fatal(err) + } + mf := &Manifest{ + ModelURL: "https://example.com/model.llamafile", + FilePath: dst, + SHA256: "abc", + Size: 6, + SetupAt: time.Now(), + } + if err := writeManifest(dir, mf); err != nil { + t.Fatal(err) + } + + m := New(Config{DataDir: dir}, nil) + if got := m.Status(); got != StatusReady { + t.Errorf("Status = %v, want StatusReady", got) + } + if !m.IsSetUp() { + t.Error("IsSetUp should be true when manifest + file exist") + } +} + +func TestManager_StatusMissing(t *testing.T) { + dir := t.TempDir() + mf := &Manifest{ + ModelURL: "https://example.com/model.llamafile", + FilePath: filepath.Join(dir, "gone.llamafile"), // file does NOT exist + SHA256: "abc", + Size: 0, + SetupAt: time.Now(), + } + if err := writeManifest(dir, mf); err != nil { + t.Fatal(err) + } + + m := New(Config{DataDir: dir}, nil) + if got := m.Status(); got != StatusMissing { + t.Errorf("Status = %v, want StatusMissing", got) + } +} + +func TestManager_Setup(t *testing.T) { + content := []byte("fake llamafile binary") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write(content) + })) + defer srv.Close() + + dir := t.TempDir() + m := New(Config{DataDir: dir, ModelURL: srv.URL + "/model.llamafile"}, nil) + + var progressCalled bool + if err := m.Setup(context.Background(), func(_, _ int64) { progressCalled = true }); err != nil { + t.Fatalf("Setup: %v", err) + } + + if !progressCalled { + t.Error("progress callback should have been called") + } + + mf, err := readManifest(dir) + if err != nil { + t.Fatalf("readManifest after Setup: %v", err) + } + if mf.ModelURL != srv.URL+"/model.llamafile" { + t.Errorf("manifest ModelURL = %q, want %q", mf.ModelURL, srv.URL+"/model.llamafile") + } + if mf.SHA256 == "" { + t.Error("manifest SHA256 should not be empty") + } + h := sha256.Sum256(content) + wantHash := hex.EncodeToString(h[:]) + if mf.SHA256 != wantHash { + t.Errorf("manifest SHA256 = %q, want %q", mf.SHA256, wantHash) + } + if mf.Size != int64(len(content)) { + t.Errorf("manifest Size = %d, want %d", mf.Size, len(content)) + } + if mf.SetupAt.IsZero() { + t.Error("manifest SetupAt should not be zero") + } + if _, err := os.Stat(mf.FilePath); err != nil { + t.Errorf("llamafile file not found: %v", err) + } + + // Status should now be Ready. + if m.Status() != StatusReady { + t.Errorf("Status after Setup = %v, want StatusReady", m.Status()) + } +} + +func TestManager_Setup_NoURL(t *testing.T) { + m := New(Config{DataDir: t.TempDir(), ModelURL: ""}, nil) + if err := m.Setup(context.Background(), nil); err == nil { + t.Fatal("want error when ModelURL is empty") + } +} + +func TestManager_Setup_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "internal error", http.StatusInternalServerError) + })) + defer srv.Close() + + dir := t.TempDir() + m := New(Config{DataDir: dir, ModelURL: srv.URL + "/model.llamafile"}, nil) + if err := m.Setup(context.Background(), nil); err == nil { + t.Fatal("want error when server returns 500") + } + // Manifest must NOT have been written. + if m.Status() != StatusNotSetUp { + t.Errorf("Status after failed Setup = %v, want StatusNotSetUp", m.Status()) + } +} + +func TestManager_Start_NotSetUp(t *testing.T) { + m := New(Config{DataDir: t.TempDir()}, nil) + _, err := m.Start(context.Background()) + if err == nil { + t.Fatal("want error when not set up") + } +} + +func TestManager_Stop_NotStarted(t *testing.T) { + m := New(Config{DataDir: t.TempDir()}, nil) + if err := m.Stop(); err != nil { + t.Errorf("Stop on not-started manager: %v", err) + } +} + +func TestManager_BaseURL_NotStarted(t *testing.T) { + m := New(Config{DataDir: t.TempDir()}, nil) + if got := m.BaseURL(); got != "" { + t.Errorf("BaseURL before Start = %q, want empty", got) + } +} + +func TestWaitHealthy_OK(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := waitHealthy(ctx, srv.URL); err != nil { + t.Errorf("waitHealthy: %v", err) + } +} + +func TestWaitHealthy_Timeout(t *testing.T) { + // Server returns 503 so health check never passes. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + if err := waitHealthy(ctx, srv.URL); err == nil { + t.Fatal("want timeout error, got nil") + } +} + +func TestWaitHealthy_ContextCancel(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := waitHealthy(ctx, srv.URL); err == nil { + t.Fatal("want error on cancelled context") + } +} + +func TestFreePort(t *testing.T) { + port, err := freePort() + if err != nil { + t.Fatalf("freePort: %v", err) + } + if port <= 0 || port > 65535 { + t.Errorf("freePort returned invalid port %d", port) + } +} + +func TestFreePort_Unique(t *testing.T) { + p1, err := freePort() + if err != nil { + t.Fatal(err) + } + p2, err := freePort() + if err != nil { + t.Fatal(err) + } + // Two consecutive calls shouldn't return the same port (bind-then-close relinquishes it, + // but the OS generally won't hand it back immediately). + _ = p1 + _ = p2 +} + +func TestManager_ReapStalePID(t *testing.T) { + dir := t.TempDir() + // Write a PID that doesn't belong to any running process (PID 0 is always invalid). + pidPath := filepath.Join(dir, pidFile) + if err := os.WriteFile(pidPath, []byte(strconv.Itoa(0)), 0600); err != nil { + t.Fatal(err) + } + + m := New(Config{DataDir: dir}, nil) + m.reapStalePID() // should not panic + + // PID file should be removed. + if _, err := os.Stat(pidPath); !os.IsNotExist(err) { + t.Error("pid file should be removed after reap") + } +} + +func TestManager_ReapStalePID_NoPIDFile(t *testing.T) { + dir := t.TempDir() + m := New(Config{DataDir: dir}, nil) + m.reapStalePID() // should be a no-op without panic +} + +func TestManager_ReapStalePID_GarbagePID(t *testing.T) { + dir := t.TempDir() + pidPath := filepath.Join(dir, pidFile) + if err := os.WriteFile(pidPath, []byte("not-a-pid"), 0600); err != nil { + t.Fatal(err) + } + + m := New(Config{DataDir: dir}, nil) + m.reapStalePID() // should not panic + + if _, err := os.Stat(pidPath); !os.IsNotExist(err) { + t.Error("garbage pid file should be removed") + } +} diff --git a/internal/slm/manifest.go b/internal/slm/manifest.go new file mode 100644 index 0000000..902c988 --- /dev/null +++ b/internal/slm/manifest.go @@ -0,0 +1,50 @@ +package slm + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +const manifestFile = "manifest.json" + +// Manifest records the result of a successful slm setup. +// Presence on disk is the "ready" invariant — written only after download succeeds. +type Manifest struct { + ModelURL string `json:"model_url"` + FilePath string `json:"file_path"` + SHA256 string `json:"sha256"` // hex-encoded SHA256 of the downloaded file + Size int64 `json:"size"` + SetupAt time.Time `json:"setup_at"` +} + +// readManifest loads the manifest from dir. Returns os.ErrNotExist if absent. +func readManifest(dir string) (*Manifest, error) { + data, err := os.ReadFile(filepath.Join(dir, manifestFile)) + if err != nil { + return nil, err + } + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("slm: corrupt manifest: %w", err) + } + return &m, nil +} + +// writeManifest atomically writes m to dir, creating dir if needed. +func writeManifest(dir string, m *Manifest) error { + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("slm: create data dir: %w", err) + } + tmp := filepath.Join(dir, manifestFile+".tmp") + if err := os.WriteFile(tmp, data, 0600); err != nil { + return err + } + return os.Rename(tmp, filepath.Join(dir, manifestFile)) +} diff --git a/internal/slm/manifest_test.go b/internal/slm/manifest_test.go new file mode 100644 index 0000000..fb57358 --- /dev/null +++ b/internal/slm/manifest_test.go @@ -0,0 +1,89 @@ +package slm + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func TestWriteAndReadManifest(t *testing.T) { + dir := t.TempDir() + want := &Manifest{ + ModelURL: "https://example.com/model.llamafile", + FilePath: "/data/model.llamafile", + SHA256: "abc123", + Size: 1024, + SetupAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), + } + + if err := writeManifest(dir, want); err != nil { + t.Fatalf("writeManifest: %v", err) + } + + got, err := readManifest(dir) + if err != nil { + t.Fatalf("readManifest: %v", err) + } + + if got.ModelURL != want.ModelURL { + t.Errorf("ModelURL: got %q, want %q", got.ModelURL, want.ModelURL) + } + if got.FilePath != want.FilePath { + t.Errorf("FilePath: got %q, want %q", got.FilePath, want.FilePath) + } + if got.SHA256 != want.SHA256 { + t.Errorf("SHA256: got %q, want %q", got.SHA256, want.SHA256) + } + if got.Size != want.Size { + t.Errorf("Size: got %d, want %d", got.Size, want.Size) + } + if !got.SetupAt.Equal(want.SetupAt) { + t.Errorf("SetupAt: got %v, want %v", got.SetupAt, want.SetupAt) + } +} + +func TestWriteManifest_CreatesDir(t *testing.T) { + base := t.TempDir() + dir := filepath.Join(base, "nested", "slm") + + m := &Manifest{ModelURL: "u", FilePath: "p", SHA256: "h", Size: 0, SetupAt: time.Now()} + if err := writeManifest(dir, m); err != nil { + t.Fatalf("writeManifest: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, manifestFile)); err != nil { + t.Fatalf("manifest file not created: %v", err) + } +} + +func TestReadManifest_NotExist(t *testing.T) { + dir := t.TempDir() + _, err := readManifest(dir) + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("want os.ErrNotExist, got %v", err) + } +} + +func TestReadManifest_CorruptJSON(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, manifestFile), []byte("not json"), 0600); err != nil { + t.Fatal(err) + } + _, err := readManifest(dir) + if err == nil { + t.Fatal("want error for corrupt JSON, got nil") + } +} + +func TestWriteManifest_Atomic(t *testing.T) { + dir := t.TempDir() + m := &Manifest{ModelURL: "u", FilePath: "p", SHA256: "h", Size: 42, SetupAt: time.Now()} + if err := writeManifest(dir, m); err != nil { + t.Fatal(err) + } + // No tmp file should remain. + if _, err := os.Stat(filepath.Join(dir, manifestFile+".tmp")); !os.IsNotExist(err) { + t.Error("temp file should not exist after successful write") + } +}