package infra import ( "bufio" "database/sql" "fmt" "os" "path/filepath" "strings" _ "github.com/mattn/go-sqlite3" ) // LocationDrift describes a discrepancy between pc_locations and the real disk state. type LocationDrift struct { EntityType string // app, analysis, project, vault EntityID string // id of the artefact DirPath string // dir_path registered or detected Status string // value in pc_locations (active/missing/archived) or "" if not registered Issue string // "missing_on_disk" | "untracked_on_disk" | "status_should_be_active" } // PcLocationsDrift compares pc_locations entries against real disk state for pcID. // If pcID is empty it is read from the first non-empty line of ~/.fn_pc. // Returns a slice of drift items (never nil, may be empty). func PcLocationsDrift(registryRoot string, pcID string) ([]LocationDrift, error) { if pcID == "" { id, err := readFnPC() if err != nil { return nil, fmt.Errorf("pc_locations_drift: cannot determine pcID: %w", err) } pcID = id } dbPath := filepath.Join(registryRoot, "registry.db") db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on") if err != nil { return nil, fmt.Errorf("pc_locations_drift: open registry.db: %w", err) } defer db.Close() // Query A: registered locations for this PC rows, err := db.Query( `SELECT entity_type, entity_id, dir_path, status FROM pc_locations WHERE pc_id = ?`, pcID, ) if err != nil { return nil, fmt.Errorf("pc_locations_drift: query pc_locations: %w", err) } type locRow struct { entityType string entityID string dirPath string status string } var registered []locRow registeredKey := map[string]locRow{} // key: entityType+"/"+entityID for rows.Next() { var r locRow if err := rows.Scan(&r.entityType, &r.entityID, &r.dirPath, &r.status); err != nil { rows.Close() return nil, fmt.Errorf("pc_locations_drift: scan: %w", err) } registered = append(registered, r) registeredKey[r.entityType+"/"+r.entityID] = r } rows.Close() if err := rows.Err(); err != nil { return nil, fmt.Errorf("pc_locations_drift: rows: %w", err) } drifts := []LocationDrift{} // Check registered entries against disk for _, r := range registered { fullPath := r.dirPath if !filepath.IsAbs(fullPath) { fullPath = filepath.Join(registryRoot, fullPath) } exists := dirExists(fullPath) if r.status == "active" && !exists { drifts = append(drifts, LocationDrift{ EntityType: r.entityType, EntityID: r.entityID, DirPath: r.dirPath, Status: r.status, Issue: "missing_on_disk", }) } else if r.status == "missing" && exists { drifts = append(drifts, LocationDrift{ EntityType: r.entityType, EntityID: r.entityID, DirPath: r.dirPath, Status: r.status, Issue: "status_should_be_active", }) } } // Query B: all indexed artefacts (apps + analysis) with dir_path type artefact struct { entityType string id string dirPath string } var artefacts []artefact for _, q := range []struct { table string entityType string }{ {"apps", "app"}, {"analysis", "analysis"}, } { arows, err := db.Query(fmt.Sprintf(`SELECT id, dir_path FROM %s WHERE dir_path != ''`, q.table)) if err != nil { // Table may not exist in all registry versions; skip gracefully continue } for arows.Next() { var a artefact a.entityType = q.entityType if err := arows.Scan(&a.id, &a.dirPath); err != nil { arows.Close() return nil, fmt.Errorf("pc_locations_drift: scan %s: %w", q.table, err) } artefacts = append(artefacts, a) } arows.Close() } // Cross: indexed artefact on disk but not in pc_locations for pcID for _, a := range artefacts { fullPath := a.dirPath if !filepath.IsAbs(fullPath) { fullPath = filepath.Join(registryRoot, fullPath) } if !dirExists(fullPath) { continue // not on this machine, that's fine } key := a.entityType + "/" + a.id if _, found := registeredKey[key]; !found { drifts = append(drifts, LocationDrift{ EntityType: a.entityType, EntityID: a.id, DirPath: a.dirPath, Status: "", Issue: "untracked_on_disk", }) } } return drifts, nil } // readFnPC reads the first non-empty, non-comment line from ~/.fn_pc. func readFnPC() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } f, err := os.Open(filepath.Join(home, ".fn_pc")) if err != nil { return "", fmt.Errorf("~/.fn_pc not found: %w", err) } defer f.Close() sc := bufio.NewScanner(f) for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line != "" && !strings.HasPrefix(line, "#") { return line, nil } } return "", fmt.Errorf("~/.fn_pc is empty") } // dirExists returns true if path is an existing directory. func dirExists(path string) bool { info, err := os.Stat(path) return err == nil && info.IsDir() }