From 5992d789414791414ed93db9c393a5295a57ac96 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 12 Apr 2026 17:29:41 +0200 Subject: [PATCH] feat: soporte projects y vaults en registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Añade tablas projects y vaults a registry.db con FTS5, modelos Go, parser de project.md y vault.yaml, CRUD completo en store, hashing determinista, validación, y soporte en el indexer para escanear projects/{name}/ con sus apps, analysis y vaults anidados. Migration 010 crea las tablas, triggers FTS5, y columna project_id en apps/analysis. El indexer preserva records remotos (repo_url) al reindexar, igual que apps/analysis. --- registry/hash.go | 29 ++- registry/indexer.go | 139 +++++++++++++- registry/migrations/010_projects.sql | 62 +++++++ registry/models.go | 31 ++++ registry/parser.go | 114 ++++++++++++ registry/store.go | 268 +++++++++++++++++++++++++-- registry/validate.go | 23 +++ 7 files changed, 647 insertions(+), 19 deletions(-) create mode 100644 registry/migrations/010_projects.sql diff --git a/registry/hash.go b/registry/hash.go index 83b28102..0d2afea8 100644 --- a/registry/hash.go +++ b/registry/hash.go @@ -77,9 +77,28 @@ func ComputeAnalysisHash(a *Analysis) string { return fmt.Sprintf("%x", h.Sum(nil)) } +// ComputeProjectHash computes a deterministic hash of all content fields of a Project. +func ComputeProjectHash(p *Project) string { + h := sha256.New() + fmt.Fprintf(h, "%s|%s|%s", + p.ID, p.Name, p.Description) + fmt.Fprintf(h, "|%s", marshalStrings(p.Tags)) + fmt.Fprintf(h, "|%s|%s|%s|%s", p.RepoURL, p.DirPath, p.Documentation, p.Notes) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +// ComputeVaultHash computes a deterministic hash of all content fields of a Vault. +func ComputeVaultHash(v *Vault) string { + h := sha256.New() + fmt.Fprintf(h, "%s|%s|%s|%s|%s|%t", + v.ID, v.Name, v.ProjectID, v.Description, v.Path, v.Symlink) + fmt.Fprintf(h, "|%s", marshalStrings(v.Tags)) + return fmt.Sprintf("%x", h.Sum(nil)) +} + // LoadTimestamps reads existing id → {created_at, updated_at, content_hash} from all tables. // Called before Purge so we can preserve dates across reindexing. -func (db *DB) LoadTimestamps() (funcs, types, apps, analysis map[string]timestampRecord, err error) { +func (db *DB) LoadTimestamps() (funcs, types, apps, analysis, projects, vaults map[string]timestampRecord, err error) { funcs, err = loadTable(db, "functions") if err != nil { return @@ -93,6 +112,14 @@ func (db *DB) LoadTimestamps() (funcs, types, apps, analysis map[string]timestam return } analysis, err = loadTable(db, "analysis") + if err != nil { + return + } + projects, err = loadTable(db, "projects") + if err != nil { + return + } + vaults, err = loadTable(db, "vaults") return } diff --git a/registry/indexer.go b/registry/indexer.go index 6970a098..f0b13374 100644 --- a/registry/indexer.go +++ b/registry/indexer.go @@ -14,6 +14,8 @@ type IndexResult struct { Types int Apps int Analysis int + Projects int + Vaults int UnitTests int ValidationErrors []string Warnings []string @@ -29,7 +31,7 @@ type IndexResult struct { // directories (e.g. python/functions/, python/types/). func Index(db *DB, root string) (*IndexResult, error) { // Load existing timestamps before purging so we can preserve created_at - oldFuncs, oldTypes, oldApps, oldAnalysis, err := db.LoadTimestamps() + oldFuncs, oldTypes, oldApps, oldAnalysis, oldProjects, oldVaults, err := db.LoadTimestamps() if err != nil { return nil, fmt.Errorf("loading timestamps: %w", err) } @@ -82,7 +84,7 @@ func Index(db *DB, root string) (*IndexResult, error) { }) } - // Parse apps from apps/*/app.md + // Parse apps from apps/*/app.md (standalone apps, no project) var apps []*App localAppIDs := make(map[string]bool) appsDir := filepath.Join(root, "apps") @@ -106,7 +108,7 @@ func Index(db *DB, root string) (*IndexResult, error) { } } - // Parse analysis from analysis/*/analysis.md + // Parse analysis from analysis/*/analysis.md (standalone, no project) var analyses []*Analysis localAnalysisIDs := make(map[string]bool) analysisDir := filepath.Join(root, "analysis") @@ -130,8 +132,111 @@ func Index(db *DB, root string) (*IndexResult, error) { } } - // Selective purge: preserve remote-only apps/analysis (have repo_url, not cloned locally) - if err := db.PurgeLocalOnly(localAppIDs, localAnalysisIDs); err != nil { + // Parse projects from projects/*/project.md + var projects []*Project + var vaults []*Vault + localProjectIDs := make(map[string]bool) + projectsDir := filepath.Join(root, "projects") + if fi, err := os.Stat(projectsDir); err == nil && fi.IsDir() { + projEntries, _ := os.ReadDir(projectsDir) + for _, pe := range projEntries { + if !pe.IsDir() { + continue + } + projName := pe.Name() + projDir := filepath.Join(projectsDir, projName) + + // Parse project.md + projMD := filepath.Join(projDir, "project.md") + if _, err := os.Stat(projMD); err != nil { + continue + } + p, err := ParseProjectMD(projMD, root) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("parse %s: %v", projMD, err)) + continue + } + projects = append(projects, p) + localProjectIDs[p.ID] = true + + // Parse project apps from projects/{name}/apps/*/app.md + projAppsDir := filepath.Join(projDir, "apps") + if fi, err := os.Stat(projAppsDir); err == nil && fi.IsDir() { + appEntries, _ := os.ReadDir(projAppsDir) + for _, ae := range appEntries { + if !ae.IsDir() { + continue + } + appMD := filepath.Join(projAppsDir, ae.Name(), "app.md") + if _, err := os.Stat(appMD); err != nil { + continue + } + a, err := ParseAppMD(appMD, root) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("parse %s: %v", appMD, err)) + continue + } + a.ProjectID = p.ID + if a.DirPath == "" { + a.DirPath = filepath.Join("projects", projName, "apps", ae.Name()) + } + apps = append(apps, a) + localAppIDs[a.ID] = true + } + } + + // Parse project analysis from projects/{name}/analysis/*/analysis.md + projAnalysisDir := filepath.Join(projDir, "analysis") + if fi, err := os.Stat(projAnalysisDir); err == nil && fi.IsDir() { + anEntries, _ := os.ReadDir(projAnalysisDir) + for _, ane := range anEntries { + if !ane.IsDir() { + continue + } + anMD := filepath.Join(projAnalysisDir, ane.Name(), "analysis.md") + if _, err := os.Stat(anMD); err != nil { + continue + } + an, err := ParseAnalysisMD(anMD, root) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("parse %s: %v", anMD, err)) + continue + } + an.ProjectID = p.ID + if an.DirPath == "" { + an.DirPath = filepath.Join("projects", projName, "analysis", ane.Name()) + } + analyses = append(analyses, an) + localAnalysisIDs[an.ID] = true + } + } + + // Parse project vaults from projects/{name}/vaults/vault.yaml + projVaultYAML := filepath.Join(projDir, "vaults", "vault.yaml") + if _, err := os.Stat(projVaultYAML); err == nil { + vs, err := ParseVaultYAML(projVaultYAML, p.ID, filepath.Join(projDir, "vaults")) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("parse %s: %v", projVaultYAML, err)) + } else { + vaults = append(vaults, vs...) + } + } + } + } + + // Parse registry-level vaults from vaults/vault.yaml + registryVaultYAML := filepath.Join(root, "vaults", "vault.yaml") + if _, err := os.Stat(registryVaultYAML); err == nil { + vs, err := ParseVaultYAML(registryVaultYAML, "", filepath.Join(root, "vaults")) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("parse %s: %v", registryVaultYAML, err)) + } else { + vaults = append(vaults, vs...) + } + } + + // Selective purge: preserve remote-only apps/analysis/projects (have repo_url, not cloned locally) + if err := db.PurgeLocalOnly(localAppIDs, localAnalysisIDs, localProjectIDs); err != nil { return nil, fmt.Errorf("purging database: %w", err) } @@ -204,6 +309,30 @@ func Index(db *DB, root string) (*IndexResult, error) { result.Analysis++ } + for _, p := range projects { + if verr := ValidateProject(p); verr != nil { + result.ValidationErrors = append(result.ValidationErrors, verr.Error()) + continue + } + p.ContentHash = ComputeProjectHash(p) + applyTimestamps(&p.CreatedAt, &p.UpdatedAt, p.ContentHash, oldProjects[p.ID], now) + if err := db.InsertProject(p); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("insert project %s: %v", p.ID, err)) + continue + } + result.Projects++ + } + + for _, v := range vaults { + v.ContentHash = ComputeVaultHash(v) + applyTimestamps(&v.CreatedAt, &v.UpdatedAt, v.ContentHash, oldVaults[v.ID], now) + if err := db.InsertVault(v); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("insert vault %s: %v", v.ID, err)) + continue + } + result.Vaults++ + } + // Extract unit tests from test files of tested functions if err := db.PurgeUnitTests(); err != nil { result.Warnings = append(result.Warnings, fmt.Sprintf("purging unit_tests: %v", err)) diff --git a/registry/migrations/010_projects.sql b/registry/migrations/010_projects.sql new file mode 100644 index 00000000..bcb51eb1 --- /dev/null +++ b/registry/migrations/010_projects.sql @@ -0,0 +1,62 @@ +-- Projects: agrupan apps, analysis y vaults bajo un tema comun. +-- Vaults: almacenes de datos (symlinks o directorios) asociados a proyectos o al registry. + +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + tags TEXT NOT NULL DEFAULT '[]', + repo_url TEXT NOT NULL DEFAULT '', + dir_path TEXT NOT NULL DEFAULT '', + documentation TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + content_hash TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE VIRTUAL TABLE IF NOT EXISTS projects_fts USING fts5( + id, + name, + description, + tags, + documentation, + notes, + content='projects', + content_rowid='rowid' +); + +CREATE TRIGGER IF NOT EXISTS projects_ai AFTER INSERT ON projects BEGIN + INSERT INTO projects_fts(rowid, id, name, description, tags, documentation, notes) + VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.documentation, new.notes); +END; + +CREATE TRIGGER IF NOT EXISTS projects_ad AFTER DELETE ON projects BEGIN + INSERT INTO projects_fts(projects_fts, rowid, id, name, description, tags, documentation, notes) + VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.documentation, old.notes); +END; + +CREATE TRIGGER IF NOT EXISTS projects_au AFTER UPDATE ON projects BEGIN + INSERT INTO projects_fts(projects_fts, rowid, id, name, description, tags, documentation, notes) + VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.documentation, old.notes); + INSERT INTO projects_fts(rowid, id, name, description, tags, documentation, notes) + VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.documentation, new.notes); +END; + +-- Vaults: almacenes de datos trackados en el registry. +CREATE TABLE IF NOT EXISTS vaults ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + project_id TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + path TEXT NOT NULL DEFAULT '', + symlink INTEGER NOT NULL DEFAULT 0, + tags TEXT NOT NULL DEFAULT '[]', + content_hash TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- Columna project_id en apps y analysis para vincular a un proyecto. +ALTER TABLE apps ADD COLUMN project_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE analysis ADD COLUMN project_id TEXT NOT NULL DEFAULT ''; diff --git a/registry/models.go b/registry/models.go index a6c4c038..165eb76e 100644 --- a/registry/models.go +++ b/registry/models.go @@ -120,6 +120,7 @@ type App struct { DirPath string `json:"dir_path"` ContentHash string `json:"content_hash"` RepoURL string `json:"repo_url"` + ProjectID string `json:"project_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -141,6 +142,7 @@ type Analysis struct { RepoURL string `json:"repo_url"` DirPath string `json:"dir_path"` ContentHash string `json:"content_hash"` + ProjectID string `json:"project_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -193,6 +195,35 @@ type UnitTest struct { UpdatedAt time.Time `json:"updated_at"` } +// Project groups apps, analysis and vaults under a common theme. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Tags []string `json:"tags"` + RepoURL string `json:"repo_url"` + DirPath string `json:"dir_path"` + Documentation string `json:"documentation"` + Notes string `json:"notes"` + ContentHash string `json:"content_hash"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Vault is a data store (symlink or directory) associated with a project or the registry. +type Vault struct { + ID string `json:"id"` + Name string `json:"name"` + ProjectID string `json:"project_id"` + Description string `json:"description"` + Path string `json:"path"` + Symlink bool `json:"symlink"` + Tags []string `json:"tags"` + ContentHash string `json:"content_hash"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // GenerateID builds the canonical ID: {name}_{lang}_{domain} func GenerateID(name, lang, domain string) string { return name + "_" + lang + "_" + domain diff --git a/registry/parser.go b/registry/parser.go index 8f46526e..5e7afb57 100644 --- a/registry/parser.go +++ b/registry/parser.go @@ -103,6 +103,27 @@ type rawAnalysis struct { RepoURL string `yaml:"repo_url"` } +// rawProject mirrors the YAML frontmatter of a project .md file. +type rawProject struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Tags []string `yaml:"tags"` + RepoURL string `yaml:"repo_url"` +} + +// rawVaultFile mirrors the YAML of a vault.yaml manifest file. +type rawVaultFile struct { + Vaults []rawVaultEntry `yaml:"vaults"` +} + +// rawVaultEntry describes a single vault in vault.yaml. +type rawVaultEntry struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Path string `yaml:"path"` + Tags []string `yaml:"tags"` +} + // extractFrontmatter splits a .md file into YAML frontmatter and body. func extractFrontmatter(data []byte) ([]byte, []byte, error) { content := data @@ -356,6 +377,99 @@ func ParseAnalysisMD(path string, root string) (*Analysis, error) { return an, nil } +// ParseProjectMD parses a project .md file into a Project. +func ParseProjectMD(path string, root string) (*Project, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + + fm, body, err := extractFrontmatter(data) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + + var raw rawProject + if err := yaml.Unmarshal(fm, &raw); err != nil { + return nil, fmt.Errorf("parsing YAML in %s: %w", path, err) + } + + if raw.Name == "" { + return nil, fmt.Errorf("%s: name is required", path) + } + if raw.Description == "" { + return nil, fmt.Errorf("%s: description is required", path) + } + + sections := extractSections(body) + + p := &Project{ + ID: raw.Name, + Name: raw.Name, + Description: raw.Description, + Tags: raw.Tags, + RepoURL: raw.RepoURL, + DirPath: filepath.Join("projects", raw.Name), + Documentation: sections.documentation, + Notes: sections.notes, + } + + return p, nil +} + +// ParseVaultYAML parses a vault.yaml manifest into a slice of Vaults. +// projectID is the owning project ID, or "" for registry-level vaults. +// vaultsDir is the directory containing vault.yaml (used to detect symlinks). +func ParseVaultYAML(path string, projectID string, vaultsDir string) ([]*Vault, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + + var raw rawVaultFile + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parsing YAML in %s: %w", path, err) + } + + var vaults []*Vault + for _, rv := range raw.Vaults { + if rv.Name == "" { + continue + } + + suffix := projectID + if suffix == "" { + suffix = "registry" + } + id := rv.Name + "_" + suffix + + // Detect if the vault entry on disk is a symlink + isSymlink := false + vaultPath := rv.Path + entryPath := filepath.Join(vaultsDir, rv.Name) + if fi, err := os.Lstat(entryPath); err == nil { + if fi.Mode()&os.ModeSymlink != 0 { + isSymlink = true + if target, err := os.Readlink(entryPath); err == nil && vaultPath == "" { + vaultPath = target + } + } + } + + vaults = append(vaults, &Vault{ + ID: id, + Name: rv.Name, + ProjectID: projectID, + Description: rv.Description, + Path: vaultPath, + Symlink: isSymlink, + Tags: rv.Tags, + }) + } + + return vaults, nil +} + // bodySections holds the extracted sections from a .md body. type bodySections struct { example string // content under ## Ejemplo diff --git a/registry/store.go b/registry/store.go index 246097ba..9bd7d3d5 100644 --- a/registry/store.go +++ b/registry/store.go @@ -291,12 +291,12 @@ func (db *DB) InsertApp(a *App) error { INSERT OR REPLACE INTO apps ( id, name, lang, domain, description, tags, uses_functions, uses_types, framework, entry_point, - documentation, notes, dir_path, content_hash, created_at, updated_at, repo_url - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + documentation, notes, dir_path, content_hash, created_at, updated_at, repo_url, project_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, a.ID, a.Name, a.Lang, a.Domain, a.Description, marshalStrings(a.Tags), marshalStrings(a.UsesFunctions), marshalStrings(a.UsesTypes), a.Framework, a.EntryPoint, a.Documentation, a.Notes, a.DirPath, a.ContentHash, a.CreatedAt.Format(time.RFC3339), a.UpdatedAt.Format(time.RFC3339), - a.RepoURL, + a.RepoURL, a.ProjectID, ) return err } @@ -363,7 +363,7 @@ func scanApps(rows interface{ Next() bool; Scan(...any) error }) ([]App, error) &a.ID, &a.Name, &a.Lang, &a.Domain, &a.Description, &tagsJSON, &usesFnJSON, &usesTypJSON, &a.Framework, &a.EntryPoint, &a.Documentation, &a.Notes, &a.DirPath, &createdAt, &updatedAt, &a.ContentHash, - &a.RepoURL, + &a.RepoURL, &a.ProjectID, ) if err != nil { return nil, fmt.Errorf("scanning app: %w", err) @@ -380,7 +380,7 @@ func scanApps(rows interface{ Next() bool; Scan(...any) error }) ([]App, error) return result, nil } -// Purge deletes all data from functions, types, apps and analysis. Used before re-indexing. +// Purge deletes all data from functions, types, apps, analysis, projects and vaults. Used before re-indexing. func (db *DB) Purge() error { if _, err := db.conn.Exec("DELETE FROM functions"); err != nil { return err @@ -391,13 +391,19 @@ func (db *DB) Purge() error { if _, err := db.conn.Exec("DELETE FROM apps"); err != nil { return err } - _, err := db.conn.Exec("DELETE FROM analysis") + if _, err := db.conn.Exec("DELETE FROM analysis"); err != nil { + return err + } + if _, err := db.conn.Exec("DELETE FROM projects"); err != nil { + return err + } + _, err := db.conn.Exec("DELETE FROM vaults") return err } -// PurgeLocalOnly deletes functions, types, and only locally-present apps/analysis. -// Remote-only records (repo_url set, not in localAppIDs/localAnalysisIDs) are preserved. -func (db *DB) PurgeLocalOnly(localAppIDs, localAnalysisIDs map[string]bool) error { +// PurgeLocalOnly deletes functions, types, and only locally-present apps/analysis/projects/vaults. +// Remote-only records (repo_url set, not in local ID maps) are preserved. +func (db *DB) PurgeLocalOnly(localAppIDs, localAnalysisIDs, localProjectIDs map[string]bool) error { if _, err := db.conn.Exec("DELETE FROM functions"); err != nil { return err } @@ -423,6 +429,19 @@ func (db *DB) PurgeLocalOnly(localAppIDs, localAnalysisIDs map[string]bool) erro if _, err := db.conn.Exec("DELETE FROM analysis WHERE repo_url = '' OR repo_url IS NULL"); err != nil { return err } + // Projects: delete locally-scanned, preserve remote-only + for id := range localProjectIDs { + if _, err := db.conn.Exec("DELETE FROM projects WHERE id = ?", id); err != nil { + return err + } + } + if _, err := db.conn.Exec("DELETE FROM projects WHERE repo_url = '' OR repo_url IS NULL"); err != nil { + return err + } + // Vaults: always purge and re-insert from vault.yaml + if _, err := db.conn.Exec("DELETE FROM vaults"); err != nil { + return err + } return nil } @@ -446,12 +465,12 @@ func (db *DB) InsertAnalysis(a *Analysis) error { INSERT OR REPLACE INTO analysis ( id, name, lang, domain, description, tags, uses_functions, uses_types, framework, entry_point, - documentation, notes, repo_url, dir_path, content_hash, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + documentation, notes, repo_url, dir_path, content_hash, created_at, updated_at, project_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, a.ID, a.Name, a.Lang, a.Domain, a.Description, marshalStrings(a.Tags), marshalStrings(a.UsesFunctions), marshalStrings(a.UsesTypes), a.Framework, a.EntryPoint, a.Documentation, a.Notes, a.RepoURL, a.DirPath, a.ContentHash, - a.CreatedAt.Format(time.RFC3339), a.UpdatedAt.Format(time.RFC3339), + a.CreatedAt.Format(time.RFC3339), a.UpdatedAt.Format(time.RFC3339), a.ProjectID, ) return err } @@ -528,7 +547,7 @@ func scanAnalysis(rows interface{ Next() bool; Scan(...any) error }) ([]Analysis &a.ID, &a.Name, &a.Lang, &a.Domain, &a.Description, &tagsJSON, &usesFnJSON, &usesTypJSON, &a.Framework, &a.EntryPoint, &a.Documentation, &a.Notes, &a.RepoURL, &a.DirPath, &a.ContentHash, - &createdAt, &updatedAt, + &createdAt, &updatedAt, &a.ProjectID, ) if err != nil { return nil, fmt.Errorf("scanning analysis: %w", err) @@ -545,6 +564,229 @@ func scanAnalysis(rows interface{ Next() bool; Scan(...any) error }) ([]Analysis return result, nil } +// --- Project CRUD --- + +// InsertProject inserts or replaces a project entry. +func (db *DB) InsertProject(p *Project) error { + now := time.Now().UTC() + if p.CreatedAt.IsZero() { + p.CreatedAt = now + } + if p.UpdatedAt.IsZero() { + p.UpdatedAt = now + } + + _, err := db.conn.Exec(` + INSERT OR REPLACE INTO projects ( + id, name, description, tags, repo_url, dir_path, + documentation, notes, content_hash, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + p.ID, p.Name, p.Description, marshalStrings(p.Tags), p.RepoURL, p.DirPath, + p.Documentation, p.Notes, p.ContentHash, + p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339), + ) + return err +} + +// GetProject returns a single project by ID. +func (db *DB) GetProject(id string) (*Project, error) { + rows, err := db.conn.Query("SELECT * FROM projects WHERE id = ?", id) + if err != nil { + return nil, err + } + defer rows.Close() + + ps, err := scanProjects(rows) + if err != nil { + return nil, err + } + if len(ps) == 0 { + return nil, fmt.Errorf("project %q not found", id) + } + return &ps[0], nil +} + +// SearchProjects performs FTS search on projects. +func (db *DB) SearchProjects(query string) ([]Project, error) { + where := []string{} + args := []any{} + + if query != "" { + where = append(where, "p.id IN (SELECT id FROM projects_fts WHERE projects_fts MATCH ?)") + args = append(args, query) + } + + sql := "SELECT * FROM projects p" + if len(where) > 0 { + sql += " WHERE " + strings.Join(where, " AND ") + } + sql += " ORDER BY p.name" + + rows, err := db.conn.Query(sql, args...) + if err != nil { + return nil, fmt.Errorf("search projects: %w", err) + } + defer rows.Close() + + return scanProjects(rows) +} + +// ListAllProjects returns all project entries. +func (db *DB) ListAllProjects() ([]Project, error) { + return db.SearchProjects("") +} + +// GetProjectApps returns all apps belonging to a project. +func (db *DB) GetProjectApps(projectID string) ([]App, error) { + rows, err := db.conn.Query("SELECT * FROM apps WHERE project_id = ? ORDER BY name", projectID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanApps(rows) +} + +// GetProjectAnalysis returns all analysis entries belonging to a project. +func (db *DB) GetProjectAnalysis(projectID string) ([]Analysis, error) { + rows, err := db.conn.Query("SELECT * FROM analysis WHERE project_id = ? ORDER BY name", projectID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAnalysis(rows) +} + +func scanProjects(rows interface{ Next() bool; Scan(...any) error }) ([]Project, error) { + var result []Project + for rows.Next() { + var p Project + var tagsJSON string + var createdAt, updatedAt string + + err := rows.Scan( + &p.ID, &p.Name, &p.Description, &tagsJSON, &p.RepoURL, &p.DirPath, + &p.Documentation, &p.Notes, &p.ContentHash, &createdAt, &updatedAt, + ) + if err != nil { + return nil, fmt.Errorf("scanning project: %w", err) + } + + p.Tags = unmarshalStrings(tagsJSON) + p.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + p.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + + result = append(result, p) + } + return result, nil +} + +// --- Vault CRUD --- + +// InsertVault inserts or replaces a vault entry. +func (db *DB) InsertVault(v *Vault) error { + now := time.Now().UTC() + if v.CreatedAt.IsZero() { + v.CreatedAt = now + } + if v.UpdatedAt.IsZero() { + v.UpdatedAt = now + } + + sym := 0 + if v.Symlink { + sym = 1 + } + + _, err := db.conn.Exec(` + INSERT OR REPLACE INTO vaults ( + id, name, project_id, description, path, symlink, tags, + content_hash, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + v.ID, v.Name, v.ProjectID, v.Description, v.Path, sym, marshalStrings(v.Tags), + v.ContentHash, v.CreatedAt.Format(time.RFC3339), v.UpdatedAt.Format(time.RFC3339), + ) + return err +} + +// GetVault returns a single vault by ID. +func (db *DB) GetVault(id string) (*Vault, error) { + rows, err := db.conn.Query("SELECT * FROM vaults WHERE id = ?", id) + if err != nil { + return nil, err + } + defer rows.Close() + + vs, err := scanVaults(rows) + if err != nil { + return nil, err + } + if len(vs) == 0 { + return nil, fmt.Errorf("vault %q not found", id) + } + return &vs[0], nil +} + +// SearchVaults performs search on vaults with optional project filter. +func (db *DB) SearchVaults(query, projectID string) ([]Vault, error) { + where := []string{} + args := []any{} + + if query != "" { + where = append(where, "name LIKE ? OR description LIKE ?") + q := "%" + query + "%" + args = append(args, q, q) + } + if projectID != "" { + where = append(where, "project_id = ?") + args = append(args, projectID) + } + + sql := "SELECT * FROM vaults" + if len(where) > 0 { + sql += " WHERE " + strings.Join(where, " AND ") + } + sql += " ORDER BY name" + + rows, err := db.conn.Query(sql, args...) + if err != nil { + return nil, fmt.Errorf("search vaults: %w", err) + } + defer rows.Close() + + return scanVaults(rows) +} + +// GetProjectVaults returns all vaults belonging to a project. +func (db *DB) GetProjectVaults(projectID string) ([]Vault, error) { + return db.SearchVaults("", projectID) +} + +func scanVaults(rows interface{ Next() bool; Scan(...any) error }) ([]Vault, error) { + var result []Vault + for rows.Next() { + var v Vault + var tagsJSON string + var createdAt, updatedAt string + var sym int + + err := rows.Scan( + &v.ID, &v.Name, &v.ProjectID, &v.Description, &v.Path, &sym, &tagsJSON, + &v.ContentHash, &createdAt, &updatedAt, + ) + if err != nil { + return nil, fmt.Errorf("scanning vault: %w", err) + } + + v.Symlink = sym == 1 + v.Tags = unmarshalStrings(tagsJSON) + v.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + v.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt) + + result = append(result, v) + } + return result, nil +} + func scanFunctions(rows interface{ Next() bool; Scan(...any) error }) ([]Function, error) { var result []Function for rows.Next() { diff --git a/registry/validate.go b/registry/validate.go index 1115efa4..8bb34d1d 100644 --- a/registry/validate.go +++ b/registry/validate.go @@ -237,6 +237,29 @@ func ValidateAnalysis(a *Analysis, knownFunctions, knownTypes map[string]bool) * return nil } +// ValidateProject checks integrity rules for projects. +func ValidateProject(p *Project) *ValidationError { + var errs []string + + if p.ID == "" { + errs = append(errs, "id is required") + } + if p.Name == "" { + errs = append(errs, "name is required") + } + if p.Description == "" { + errs = append(errs, "description is required") + } + if p.DirPath != "" && strings.HasPrefix(p.DirPath, "/") { + errs = append(errs, "dir_path must be relative to registry root") + } + + if len(errs) > 0 { + return &ValidationError{ID: p.ID, Errors: errs} + } + return nil +} + // ValidateType checks integrity rules for types. func ValidateType(t *Type, knownTypes map[string]bool) *ValidationError { var errs []string