diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 964784e..386ba0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: run: | total=$(go tool cover -func=coverage.out | awk '/^total:/ {gsub("%","",$3); print $3}') # Current enforced coverage floor. Codex PRs raise this incrementally toward 90%. - min=45.0 + min=50.0 awk -v t="$total" -v m="$min" 'BEGIN { if (t+0 < m+0) { printf "Coverage %.1f%% is below floor %.1f%%\n", t, m diff --git a/cmd/codemapd_socket.go b/cmd/codemapd_socket.go new file mode 100644 index 0000000..cd2b9be --- /dev/null +++ b/cmd/codemapd_socket.go @@ -0,0 +1,339 @@ +package cmd + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "codemap/watch" +) + +const ( + sockdMagic = 0x534f434b + sockdVersion = 1 + sockdHeaderLen = 12 + sockdMaxPayloadLen = 16 * 1024 * 1024 + frameKindRequest = 1 + frameKindResponse = 2 + frameKindError = 5 + frameKindShutdown = 6 + frameKindShutdownAck = 7 + + codemapdDialTimeout = 150 * time.Millisecond + codemapdQueryTimeout = 500 * time.Millisecond +) + +type codemapdQuery struct { + Kind string `json:"kind"` + Limit int `json:"limit,omitempty"` +} + +type codemapdHealth struct { + Status string `json:"status"` +} + +type codemapdHubInfoResponse struct { + UpdatedAt time.Time `json:"updated_at"` + FileCount int `json:"file_count"` + Hubs []string `json:"hubs"` + Importers map[string][]string `json:"importers"` + Imports map[string][]string `json:"imports"` +} + +type codemapdWorkingSetResponse struct { + UpdatedAt time.Time `json:"updated_at"` + WorkingSet *watch.WorkingSet `json:"working_set,omitempty"` +} + +type codemapdRecentEventsResponse struct { + UpdatedAt time.Time `json:"updated_at"` + RecentEvents []watch.Event `json:"recent_events"` +} + +type codemapdProjectStatsResponse struct { + UpdatedAt time.Time `json:"updated_at"` + FileCount int `json:"file_count"` + Hubs []string `json:"hubs"` +} + +func loadWorkingSet(root string) *watch.WorkingSet { + if workingSet, err := querySocketWorkingSet(root); err == nil && workingSet != nil { + return workingSet + } + + state := watch.ReadState(root) + if state == nil { + return nil + } + return state.WorkingSet +} + +func loadRecentEvents(root string, limit int) []watch.Event { + if events, err := querySocketRecentEvents(root, limit); err == nil { + return events + } + + state := watch.ReadState(root) + if state == nil { + return nil + } + if limit <= 0 || len(state.RecentEvents) <= limit { + return state.RecentEvents + } + return state.RecentEvents[len(state.RecentEvents)-limit:] +} + +func loadProjectStats(root string) (*codemapdProjectStatsResponse, bool) { + if stats, err := querySocketProjectStats(root); err == nil && stats != nil { + return stats, true + } + + state := watch.ReadState(root) + if state == nil { + return nil, false + } + return &codemapdProjectStatsResponse{ + UpdatedAt: state.UpdatedAt, + FileCount: state.FileCount, + Hubs: append([]string(nil), state.Hubs...), + }, true +} + +func querySocketHubInfo(root string) (*hubInfo, error) { + var response codemapdHubInfoResponse + if err := queryCodemapd(root, codemapdQuery{Kind: "hub_info"}, &response); err != nil { + return nil, err + } + if len(response.Importers) == 0 && len(response.Imports) == 0 && len(response.Hubs) == 0 { + return nil, nil + } + return &hubInfo{ + Hubs: response.Hubs, + Importers: response.Importers, + Imports: response.Imports, + }, nil +} + +func querySocketWorkingSet(root string) (*watch.WorkingSet, error) { + var response codemapdWorkingSetResponse + if err := queryCodemapd(root, codemapdQuery{Kind: "working_set"}, &response); err != nil { + return nil, err + } + return response.WorkingSet, nil +} + +func querySocketRecentEvents(root string, limit int) ([]watch.Event, error) { + var response codemapdRecentEventsResponse + if err := queryCodemapd(root, codemapdQuery{Kind: "recent_events", Limit: limit}, &response); err != nil { + return nil, err + } + return response.RecentEvents, nil +} + +func querySocketProjectStats(root string) (*codemapdProjectStatsResponse, error) { + var response codemapdProjectStatsResponse + if err := queryCodemapd(root, codemapdQuery{Kind: "project_stats"}, &response); err != nil { + return nil, err + } + return &response, nil +} + +func queryCodemapd(root string, request any, out any) error { + if runtime.GOOS == "windows" { + return errors.New("codemapd is not available on windows") + } + + payload, err := json.Marshal(request) + if err != nil { + return err + } + + conn, err := net.DialTimeout("unix", codemapdSocketPath(root), codemapdDialTimeout) + if err != nil { + return err + } + defer conn.Close() + + _ = conn.SetDeadline(time.Now().Add(codemapdQueryTimeout)) + + if err := writeSockdFrame(conn, frameKindRequest, payload); err != nil { + return err + } + + kind, responsePayload, err := readSockdFrame(conn) + if err != nil { + return err + } + + switch kind { + case frameKindResponse: + if out == nil { + return nil + } + return json.Unmarshal(responsePayload, out) + case frameKindError: + return errors.New(strings.TrimSpace(string(responsePayload))) + default: + return fmt.Errorf("unexpected codemapd frame kind: %d", kind) + } +} + +func startSocketDaemon(root string) { + if runtime.GOOS == "windows" || codemapdHealthy(root) { + return + } + + binaryPath, ok := resolveCodemapdBinary() + if !ok { + return + } + + cmd := hookExecCommand(binaryPath, "--root", root) + nullFile, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) + if err != nil { + return + } + defer nullFile.Close() + + cmd.Stdout = nullFile + cmd.Stderr = nullFile + cmd.Stdin = nullFile + if err := cmd.Start(); err != nil { + return + } + + deadline := time.Now().Add(750 * time.Millisecond) + for time.Now().Before(deadline) { + if codemapdHealthy(root) { + return + } + time.Sleep(50 * time.Millisecond) + } +} + +func stopSocketDaemon(root string) { + if runtime.GOOS == "windows" { + return + } + + conn, err := net.DialTimeout("unix", codemapdSocketPath(root), codemapdDialTimeout) + if err != nil { + return + } + defer conn.Close() + + _ = conn.SetDeadline(time.Now().Add(codemapdQueryTimeout)) + if err := writeSockdFrame(conn, frameKindShutdown, nil); err != nil { + return + } + + kind, _, err := readSockdFrame(conn) + if err != nil { + return + } + if kind != frameKindShutdownAck { + return + } +} + +func codemapdHealthy(root string) bool { + var response codemapdHealth + if err := queryCodemapd(root, codemapdQuery{Kind: "health"}, &response); err != nil { + return false + } + return response.Status == "ok" +} + +func resolveCodemapdBinary() (string, bool) { + if runtime.GOOS == "windows" { + return "", false + } + + if envPath := strings.TrimSpace(os.Getenv("CODEMAPD_BIN")); envPath != "" { + if info, err := os.Stat(envPath); err == nil && !info.IsDir() { + return envPath, true + } + } + + exe, err := hookExecutablePath() + if err != nil { + return "", false + } + + candidate := filepath.Join(filepath.Dir(exe), "codemapd") + info, err := os.Stat(candidate) + if err != nil || info.IsDir() { + return "", false + } + return candidate, true +} + +func codemapdSocketPath(root string) string { + return filepath.Join(root, ".codemap", "codemapd.sock") +} + +func writeSockdFrame(w io.Writer, kind byte, payload []byte) error { + if len(payload) > sockdMaxPayloadLen { + return fmt.Errorf("codemapd payload too large: %d", len(payload)) + } + + var header [sockdHeaderLen]byte + binary.BigEndian.PutUint32(header[0:4], sockdMagic) + header[4] = sockdVersion + header[5] = kind + binary.BigEndian.PutUint16(header[6:8], 0) + binary.BigEndian.PutUint32(header[8:12], uint32(len(payload))) + + if err := writeAll(w, header[:]); err != nil { + return err + } + if len(payload) == 0 { + return nil + } + return writeAll(w, payload) +} + +func readSockdFrame(r io.Reader) (byte, []byte, error) { + var header [sockdHeaderLen]byte + if _, err := io.ReadFull(r, header[:]); err != nil { + return 0, nil, err + } + + if magic := binary.BigEndian.Uint32(header[0:4]); magic != sockdMagic { + return 0, nil, fmt.Errorf("invalid codemapd frame magic: 0x%x", magic) + } + if version := header[4]; version != sockdVersion { + return 0, nil, fmt.Errorf("unsupported codemapd frame version: %d", version) + } + + payloadLen := binary.BigEndian.Uint32(header[8:12]) + if payloadLen > sockdMaxPayloadLen { + return 0, nil, fmt.Errorf("codemapd frame payload too large: %d", payloadLen) + } + + payload := make([]byte, int(payloadLen)) + if _, err := io.ReadFull(r, payload); err != nil { + return 0, nil, err + } + + return header[5], payload, nil +} + +func writeAll(w io.Writer, payload []byte) error { + for len(payload) > 0 { + n, err := w.Write(payload) + if err != nil { + return err + } + payload = payload[n:] + } + return nil +} diff --git a/cmd/context.go b/cmd/context.go index 7d7594c..eed03ff 100644 --- a/cmd/context.go +++ b/cmd/context.go @@ -15,7 +15,6 @@ import ( "codemap/handoff" "codemap/scanner" "codemap/skills" - "codemap/watch" ) // ContextEnvelope is the standardized output format that any AI tool can consume. @@ -145,8 +144,7 @@ func buildContextEnvelope(root, prompt string, compact bool) ContextEnvelope { } // Working set from daemon - if state := watch.ReadState(root); state != nil && state.WorkingSet != nil { - ws := state.WorkingSet + if ws := loadWorkingSet(root); ws != nil { wsCtx := &WorkingSetContext{ FileCount: ws.Size(), HubCount: ws.HubCount(), @@ -197,13 +195,13 @@ func buildProjectContext(root string, info *hubInfo) ProjectContext { } // Count files and detect languages from daemon state - if state := watch.ReadState(root); state != nil { - ctx.FileCount = state.FileCount - ctx.HubCount = len(state.Hubs) - if len(state.Hubs) > 5 { - ctx.TopHubs = state.Hubs[:5] + if stats, ok := loadProjectStats(root); ok { + ctx.FileCount = stats.FileCount + ctx.HubCount = len(stats.Hubs) + if len(stats.Hubs) > 5 { + ctx.TopHubs = stats.Hubs[:5] } else { - ctx.TopHubs = state.Hubs + ctx.TopHubs = stats.Hubs } } diff --git a/cmd/hooks.go b/cmd/hooks.go index c834cd2..964544f 100644 --- a/cmd/hooks.go +++ b/cmd/hooks.go @@ -108,6 +108,15 @@ func getHubInfoNoFallback(root string) *hubInfo { } func getHubInfoWithFallback(root string, allowFallback bool) *hubInfo { + if info, err := querySocketHubInfo(root); err == nil { + if info != nil { + return info + } + if !allowFallback { + return nil + } + } + if state := watch.ReadState(root); state != nil { // State may contain file/event info only (no dependency graph) on very // large repos. Avoid expensive fallback scans in that case. @@ -273,6 +282,7 @@ func hookSessionStart(root string) error { if !watch.IsRunning(root) { startDaemon(root) } + startSocketDaemon(root) fmt.Println("๐Ÿ“ Project Context:") fmt.Println() @@ -288,13 +298,18 @@ func hookSessionStart(root string) error { // Future: Consider structured output that Claude Code can format/truncate intelligently. fileCount := 0 fileCountKnown := false - state := watch.ReadState(root) - if state == nil && watch.IsRunning(root) { - state = waitForDaemonState(root, 2*time.Second) - } - if state != nil { - fileCount = state.FileCount + if stats, ok := loadProjectStats(root); ok { + fileCount = stats.FileCount fileCountKnown = true + } else { + state := watch.ReadState(root) + if state == nil && watch.IsRunning(root) { + state = waitForDaemonState(root, 2*time.Second) + } + if state != nil { + fileCount = state.FileCount + fileCountKnown = true + } } projCfg := config.Load(root) structureBudget := projCfg.SessionStartOutputBytes() @@ -885,12 +900,11 @@ func showDriftWarnings(root string, cfg config.DriftConfig, routing config.Routi // showWorkingSetSummary displays the current working set from daemon state. func showWorkingSetSummary(root string) { - state := watch.ReadState(root) - if state == nil || state.WorkingSet == nil || state.WorkingSet.Size() == 0 { + ws := loadWorkingSet(root) + if ws == nil || ws.Size() == 0 { return } - ws := state.WorkingSet hot := ws.HotFiles(5) if len(hot) == 0 { return @@ -1048,15 +1062,15 @@ func emitRouteMarker(matches []subsystemRouteMatch) { // showSessionProgress shows files edited so far in this session func showSessionProgress(root string) { - state := watch.ReadState(root) - if state == nil || len(state.RecentEvents) == 0 { + events := loadRecentEvents(root, 0) + if len(events) == 0 { return } // Count unique files and unique hub files edited filesEdited := make(map[string]bool) hubFiles := make(map[string]bool) - for _, e := range state.RecentEvents { + for _, e := range events { filesEdited[e.Path] = true if e.IsHub { hubFiles[e.Path] = true @@ -1113,6 +1127,7 @@ func hookPreCompact(root string) error { func hookSessionStop(root string) error { // Read state BEFORE stopping daemon (includes timeline) state := watch.ReadState(root) + recentEvents := loadRecentEvents(root, 0) // Stop the watch daemon stopDaemon(root) @@ -1122,7 +1137,7 @@ func hookSessionStop(root string) error { fmt.Println("==================") // Show timeline from daemon events (if available) - if state != nil && len(state.RecentEvents) > 0 { + if len(recentEvents) > 0 { fmt.Println() fmt.Println("Edit Timeline:") @@ -1131,7 +1146,7 @@ func hookSessionStop(root string) error { fileEdits := make(map[string]int) // file -> edit count hubEdits := 0 - for _, e := range state.RecentEvents { + for _, e := range recentEvents { totalDelta += e.Delta fileEdits[e.Path]++ if e.IsHub { @@ -1140,7 +1155,7 @@ func hookSessionStop(root string) error { } // Show last 10 events - events := state.RecentEvents + events := recentEvents start := 0 if len(events) > 10 { start = len(events) - 10 @@ -1172,7 +1187,7 @@ func hookSessionStop(root string) error { // Show stats fmt.Println() fmt.Printf("Stats: %d events, %d files touched, %+d lines", - len(state.RecentEvents), len(fileEdits), totalDelta) + len(recentEvents), len(fileEdits), totalDelta) if hubEdits > 0 { fmt.Printf(", %d hub edits", hubEdits) } @@ -1334,6 +1349,8 @@ func gitSymbolicRef(root, ref string) (string, bool) { // stopDaemon stops the watch daemon func stopDaemon(root string) { + stopSocketDaemon(root) + if !hookWatchIsRunning(root) { return } diff --git a/cmd/serve.go b/cmd/serve.go index 33e2f1d..4ad5cb5 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -11,7 +11,6 @@ import ( "time" "codemap/skills" - "codemap/watch" ) // RunServe starts a lightweight HTTP server exposing codemap's intelligence. @@ -112,12 +111,12 @@ func RunServe(args []string, root string) { // GET /api/working-set โ€” current session working set mux.HandleFunc("/api/working-set", func(w http.ResponseWriter, r *http.Request) { - state := watch.ReadState(absRoot) - if state == nil || state.WorkingSet == nil { + workingSet := loadWorkingSet(absRoot) + if workingSet == nil { writeJSON(w, map[string]string{"status": "no working set available"}) return } - writeJSON(w, state.WorkingSet) + writeJSON(w, workingSet) }) // GET /api/health โ€” simple health check diff --git a/codemapd/Cargo.lock b/codemapd/Cargo.lock new file mode 100644 index 0000000..4ce8f9a --- /dev/null +++ b/codemapd/Cargo.lock @@ -0,0 +1,167 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "codemapd" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sockd", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "sockd" +version = "0.0.1" +dependencies = [ + "libc", + "signal-hook", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/codemapd/Cargo.toml b/codemapd/Cargo.toml new file mode 100644 index 0000000..80196e4 --- /dev/null +++ b/codemapd/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "codemapd" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sockd = { path = "../../sockd" } diff --git a/codemapd/src/main.rs b/codemapd/src/main.rs new file mode 100644 index 0000000..9d763ad --- /dev/null +++ b/codemapd/src/main.rs @@ -0,0 +1,322 @@ +use serde::{Deserialize, Serialize}; +use sockd::Daemon; +use std::collections::HashMap; +use std::env; +use std::error::Error; +use std::fs; +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +type BoxError = Box; + +#[derive(Debug)] +struct Config { + socket_path: PathBuf, + pid_file: PathBuf, + state_file: PathBuf, + idle_timeout: Duration, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum Query { + Health, + HubInfo, + WorkingSet, + RecentEvents { limit: Option }, + ProjectStats, +} + +/// Deserialize a field that may be null or missing as an empty Vec. +/// Go's json.Marshal writes `null` for nil slices, but serde's #[default] +/// only handles missing fields, not explicit nulls. +fn null_as_empty_vec<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + Ok(Option::>::deserialize(deserializer)?.unwrap_or_default()) +} + +fn null_as_empty_map<'de, D, V>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + V: Deserialize<'de>, +{ + Ok(Option::>::deserialize(deserializer)?.unwrap_or_default()) +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct CodemapState { + #[serde(default)] + updated_at: String, + #[serde(default)] + file_count: usize, + #[serde(default, deserialize_with = "null_as_empty_vec")] + hubs: Vec, + #[serde(default, deserialize_with = "null_as_empty_map")] + importers: HashMap>, + #[serde(default, deserialize_with = "null_as_empty_map")] + imports: HashMap>, + #[serde(default, deserialize_with = "null_as_empty_vec")] + recent_events: Vec, + #[serde(default)] + working_set: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct RecentEvent { + #[serde(default)] + time: String, + #[serde(default)] + op: String, + #[serde(default)] + path: String, + #[serde(default)] + lang: String, + #[serde(default)] + lines: i64, + #[serde(default)] + delta: i64, + #[serde(default)] + size_delta: i64, + #[serde(default)] + dirty: bool, + #[serde(default)] + importers: usize, + #[serde(default)] + imports: usize, + #[serde(default)] + is_hub: bool, + #[serde(default)] + related_hot: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct WorkingSet { + #[serde(default)] + files: HashMap, + #[serde(default)] + started_at: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct WorkingFile { + #[serde(default)] + path: String, + #[serde(default)] + first_touch: String, + #[serde(default)] + last_touch: String, + #[serde(default)] + edit_count: usize, + #[serde(default)] + net_delta: i64, + #[serde(default)] + is_hub: bool, + #[serde(default)] + importers: usize, +} + +#[derive(Debug, Serialize)] +struct HubInfoResponse { + updated_at: String, + file_count: usize, + hubs: Vec, + importers: HashMap>, + imports: HashMap>, +} + +#[derive(Debug, Serialize)] +struct WorkingSetResponse { + updated_at: String, + working_set: Option, +} + +#[derive(Debug, Serialize)] +struct RecentEventsResponse { + updated_at: String, + recent_events: Vec, +} + +#[derive(Debug, Serialize)] +struct ProjectStatsResponse { + updated_at: String, + file_count: usize, + hubs: Vec, +} + +#[derive(Debug, Serialize)] +struct HealthResponse { + status: &'static str, +} + +#[derive(Debug)] +struct StateCache { + state_file: PathBuf, + modified_at: Option, + cached: Option, +} + +impl StateCache { + fn new(state_file: PathBuf) -> Self { + Self { + state_file, + modified_at: None, + cached: None, + } + } + + fn handle(&mut self, payload: &[u8]) -> Result, BoxError> { + let query: Query = serde_json::from_slice(payload)?; + match query { + Query::Health => { + serde_json::to_vec(&HealthResponse { status: "ok" }).map_err(Into::into) + } + Query::HubInfo => { + let state = self.load_state()?; + serde_json::to_vec(&HubInfoResponse { + updated_at: state.updated_at.clone(), + file_count: state.file_count, + hubs: state.hubs.clone(), + importers: state.importers.clone(), + imports: state.imports.clone(), + }) + .map_err(Into::into) + } + Query::WorkingSet => { + let state = self.load_state()?; + serde_json::to_vec(&WorkingSetResponse { + updated_at: state.updated_at.clone(), + working_set: state.working_set.clone(), + }) + .map_err(Into::into) + } + Query::RecentEvents { limit } => { + let state = self.load_state()?; + let recent_events = trim_recent_events(&state.recent_events, limit); + serde_json::to_vec(&RecentEventsResponse { + updated_at: state.updated_at.clone(), + recent_events, + }) + .map_err(Into::into) + } + Query::ProjectStats => { + let state = self.load_state()?; + serde_json::to_vec(&ProjectStatsResponse { + updated_at: state.updated_at.clone(), + file_count: state.file_count, + hubs: state.hubs.clone(), + }) + .map_err(Into::into) + } + } + } + + fn load_state(&mut self) -> Result<&CodemapState, BoxError> { + let metadata = fs::metadata(&self.state_file)?; + let modified_at = metadata.modified()?; + + let needs_reload = self.cached.is_none() || self.modified_at != Some(modified_at); + if needs_reload { + let bytes = fs::read(&self.state_file)?; + let state: CodemapState = serde_json::from_slice(&bytes)?; + self.cached = Some(state); + self.modified_at = Some(modified_at); + } + + self.cached + .as_ref() + .ok_or_else(|| "codemap state cache unavailable".into()) + } +} + +fn trim_recent_events(events: &[RecentEvent], limit: Option) -> Vec { + let Some(limit) = limit else { + return events.to_vec(); + }; + if limit == 0 || events.len() <= limit { + return events.to_vec(); + } + events[events.len() - limit..].to_vec() +} + +fn main() -> Result<(), BoxError> { + let config = parse_args(env::args().skip(1))?; + if let Some(parent) = config.socket_path.parent() { + fs::create_dir_all(parent)?; + } + + let state_file = config.state_file.clone(); + let daemon = Daemon::builder() + .socket(&config.socket_path) + .pid_file(&config.pid_file) + .idle_timeout(config.idle_timeout) + .on_start(move || Ok(StateCache::new(state_file.clone()))) + .on_request(|cache, payload| cache.handle(payload)) + .build()?; + + daemon.run()?; + Ok(()) +} + +fn parse_args(args: I) -> Result +where + I: IntoIterator, +{ + let mut root: Option = None; + let mut socket_path: Option = None; + let mut pid_file: Option = None; + let mut state_file: Option = None; + let mut idle_secs = 300u64; + + let mut iter = args.into_iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--root" => root = Some(PathBuf::from(next_arg(&mut iter, "--root")?)), + "--socket" => { + socket_path = Some(PathBuf::from(next_arg(&mut iter, "--socket")?)); + } + "--pid-file" => { + pid_file = Some(PathBuf::from(next_arg(&mut iter, "--pid-file")?)); + } + "--state-file" => { + state_file = Some(PathBuf::from(next_arg(&mut iter, "--state-file")?)); + } + "--idle-secs" => { + idle_secs = next_arg(&mut iter, "--idle-secs")?.parse()?; + } + "--help" | "-h" => { + print_usage(); + std::process::exit(0); + } + other => { + return Err(format!("unexpected argument: {other}").into()); + } + } + } + + let root = root.ok_or_else(|| "--root is required".to_string())?; + let codemap_dir = root.join(".codemap"); + + Ok(Config { + socket_path: socket_path.unwrap_or_else(|| codemap_dir.join("codemapd.sock")), + pid_file: pid_file.unwrap_or_else(|| codemap_dir.join("codemapd.pid")), + state_file: state_file.unwrap_or_else(|| codemap_dir.join("state.json")), + idle_timeout: Duration::from_secs(idle_secs), + }) +} + +fn next_arg(iter: &mut I, flag: &str) -> Result +where + I: Iterator, +{ + iter.next() + .ok_or_else(|| format!("{flag} requires a value").into()) +} + +fn print_usage() { + eprintln!( + "Usage: codemapd --root [--socket ] [--pid-file ] [--state-file ] [--idle-secs ]" + ); +} diff --git a/render/depgraph_test.go b/render/depgraph_test.go index 0cabb75..7ad6296 100644 --- a/render/depgraph_test.go +++ b/render/depgraph_test.go @@ -2,6 +2,8 @@ package render import ( "bytes" + "os" + "path/filepath" "strings" "testing" @@ -103,3 +105,73 @@ func TestDepgraphRendersExternalDepsAndSummarySection(t *testing.T) { } } } + +func writeDepgraphFixture(t *testing.T, root string) { + t.Helper() + + files := map[string]string{ + "go.mod": "module example.com/demo\n\ngo 1.24.0\n", + "app/main.go": "package app\n\nimport (\n\t\"example.com/demo/core/extra1\"\n\t\"example.com/demo/core/extra2\"\n\t\"example.com/demo/core/leaf\"\n\t\"example.com/demo/core/mid\"\n\t\"example.com/demo/core/root\"\n)\n\nfunc Main() {\n\textra1.Extra1()\n\textra2.Extra2()\n\tleaf.Leaf()\n\tmid.Mid()\n\troot.Root()\n}\n", + "core/root/root.go": "package root\n\nimport \"example.com/demo/core/mid\"\n\nfunc Root() {\n\tmid.Mid()\n}\n", + "core/mid/mid.go": "package mid\n\nimport \"example.com/demo/core/leaf\"\n\nfunc Mid() {\n\tleaf.Leaf()\n}\n", + "core/leaf/leaf.go": "package leaf\n\nfunc Leaf() {}\n", + "core/extra1/extra1.go": "package extra1\n\nimport \"example.com/demo/core/leaf\"\n\nfunc Extra1() {\n\tleaf.Leaf()\n}\n", + "core/extra2/extra2.go": "package extra2\n\nimport \"example.com/demo/core/leaf\"\n\nfunc Extra2() {\n\tleaf.Leaf()\n}\n", + } + + for path, content := range files { + full := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func TestDepgraphRendersChainsFanoutAndHubs(t *testing.T) { + if !scanner.NewAstGrepAnalyzer().Available() { + t.Skip("ast-grep not available") + } + + root := t.TempDir() + writeDepgraphFixture(t, root) + + project := scanner.DepsProject{ + Root: root, + Files: []scanner.FileAnalysis{ + {Path: "app/main.go", Functions: []string{"Main"}}, + {Path: "core/root/root.go", Functions: []string{"Root"}}, + {Path: "core/mid/mid.go", Functions: []string{"Mid"}}, + {Path: "core/leaf/leaf.go", Functions: []string{"Leaf"}}, + {Path: "core/extra1/extra1.go", Functions: []string{"Extra1"}}, + {Path: "core/extra2/extra2.go", Functions: []string{"Extra2"}}, + }, + ExternalDeps: map[string][]string{ + "go": {"example.com/very/long/module/name/v2"}, + }, + } + + var buf bytes.Buffer + Depgraph(&buf, project) + output := buf.String() + + expectedSnippets := []string{ + "Dependency Flow", + "Go: name", + "App", + "Core", + "main โ”€โ”€โ”ฌโ”€โ”€โ–ถ core/extra1/extra1", + "โ””โ”€โ”€โ–ถ core/root/root", + "root โ”€โ”€โ”€โ–ถ core/mid/mid", + "HUBS: core/leaf/leaf (4โ†), core/mid/mid (2โ†)", + "6 files ยท 6 functions ยท 9 deps", + } + + for _, snippet := range expectedSnippets { + if !strings.Contains(output, snippet) { + t.Fatalf("expected output to contain %q, got:\n%s", snippet, output) + } + } +}