sqry_core/workspace/registry.rs
1//! Workspace registry data structures and persistence helpers.
2//!
3//! ## On-disk schema versions
4//!
5//! - **v1** (legacy): single flat `repositories` array. Carries
6//! `metadata { version: 1 }` and a list of [`WorkspaceRepository`] entries.
7//! - **v2** (current): `source_roots` (renamed from `repositories`),
8//! `member_folders`, `exclusions`, `project_root_mode`. Carries
9//! `metadata { version: 2 }`.
10//!
11//! ## Upgrade path
12//!
13//! Loading a v1 file via [`WorkspaceRegistry::load`] auto-upgrades it in
14//! memory: each v1 repository entry becomes a v2 source root, and the
15//! v2-only collections are initialized empty / default. The next
16//! [`WorkspaceRegistry::save`] persists v2.
17//!
18//! Loading a file with `metadata.version > 2` returns
19//! [`WorkspaceError::UnsupportedVersion`]; we never silently downgrade
20//! a future schema.
21
22use std::collections::BTreeMap;
23use std::fs::{self, File};
24use std::path::{Path, PathBuf};
25use std::time::SystemTime;
26
27use serde::{Deserialize, Serialize};
28
29use super::error::{WorkspaceError, WorkspaceResult};
30use super::logical::MemberReason;
31use super::serde_time;
32use crate::graph::unified::persistence::{GraphStorage, ManifestCheck};
33use crate::project::types::ProjectRootMode;
34
35/// Current on-disk registry format version.
36pub const WORKSPACE_REGISTRY_VERSION: u32 = 2;
37
38/// Serializable workspace registry stored in `.sqry-workspace`.
39///
40/// On-disk layout corresponds to schema **v2** — see the module-level
41/// docs for the v1 → v2 upgrade contract.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct WorkspaceRegistry {
44 /// Registry metadata (versioning, timestamps).
45 pub metadata: WorkspaceMetadata,
46 /// Auto-indexed source roots. Persisted under the `source_roots`
47 /// JSON key in v2; deserialization also accepts the v1
48 /// `repositories` key (see [`Self::load`]).
49 #[serde(default, rename = "source_roots", alias = "repositories")]
50 pub repositories: Vec<WorkspaceRepository>,
51 /// Workspace member folders — part of the workspace but **not**
52 /// auto-indexed (reads still resolve through the workspace's source
53 /// roots). Empty in v1.
54 #[serde(default)]
55 pub member_folders: Vec<WorkspaceMemberFolder>,
56 /// Explicitly excluded paths (opaque to sqry). Empty in v1.
57 #[serde(default)]
58 pub exclusions: Vec<PathBuf>,
59 /// Workspace-scoped project-root resolution mode.
60 /// Defaults to [`ProjectRootMode::default`] (= `GitRoot`) when absent.
61 #[serde(default)]
62 pub project_root_mode: ProjectRootMode,
63}
64
65impl WorkspaceRegistry {
66 /// Construct a new empty registry at the current schema version.
67 #[must_use]
68 pub fn new(workspace_name: Option<String>) -> Self {
69 Self {
70 metadata: WorkspaceMetadata::new(workspace_name),
71 repositories: Vec::new(),
72 member_folders: Vec::new(),
73 exclusions: Vec::new(),
74 project_root_mode: ProjectRootMode::default(),
75 }
76 }
77
78 /// Load a registry from `path`.
79 ///
80 /// Schema v1 files (single-flat `repositories` array) are auto-upgraded
81 /// to v2 in memory: existing entries become source roots; member-folder,
82 /// exclusion, and project-root-mode fields are initialised to their
83 /// defaults. Files with `metadata.version > 2` are rejected.
84 ///
85 /// # Errors
86 ///
87 /// Returns [`WorkspaceError::Io`] when the file cannot be read,
88 /// [`WorkspaceError::Serialization`] when the JSON is malformed, and
89 /// [`WorkspaceError::UnsupportedVersion`] when the on-disk version is
90 /// newer than this build understands.
91 pub fn load(path: &Path) -> WorkspaceResult<Self> {
92 let file = File::open(path).map_err(|err| WorkspaceError::io(path, err))?;
93 let mut registry: WorkspaceRegistry =
94 serde_json::from_reader(file).map_err(WorkspaceError::Serialization)?;
95
96 match registry.metadata.version {
97 1 => {
98 // v1 → v2 upgrade: keep already-deserialized `repositories`
99 // (the `alias = "repositories"` lets us read the v1 key as
100 // `source_roots`), default the v2-only collections, and
101 // bump the in-memory version. The next `save()` writes v2.
102 registry.member_folders = Vec::new();
103 registry.exclusions = Vec::new();
104 registry.project_root_mode = ProjectRootMode::default();
105 registry.metadata.version = WORKSPACE_REGISTRY_VERSION;
106 }
107 2 => {}
108 other => {
109 return Err(WorkspaceError::UnsupportedVersion {
110 found: other,
111 expected: WORKSPACE_REGISTRY_VERSION,
112 });
113 }
114 }
115
116 registry.sort_repositories();
117 Ok(registry)
118 }
119
120 /// Persist the registry to `path`, creating parent directories if
121 /// necessary. Always writes the current ([`WORKSPACE_REGISTRY_VERSION`])
122 /// schema regardless of how the in-memory representation was loaded.
123 ///
124 /// # Errors
125 ///
126 /// Returns [`WorkspaceError`] when directories cannot be created or
127 /// serialization fails.
128 pub fn save(&mut self, path: &Path) -> WorkspaceResult<()> {
129 if let Some(parent) = path.parent() {
130 fs::create_dir_all(parent).map_err(|err| WorkspaceError::io(parent, err))?;
131 }
132
133 self.sort_repositories();
134 self.metadata.version = WORKSPACE_REGISTRY_VERSION;
135 self.metadata.touch_updated();
136
137 let file = File::create(path).map_err(|err| WorkspaceError::io(path, err))?;
138 serde_json::to_writer_pretty(file, self).map_err(WorkspaceError::Serialization)
139 }
140
141 /// Insert or update a source-root repository entry.
142 ///
143 /// # Errors
144 ///
145 /// Returns [`WorkspaceError`] when persistence metadata updates fail
146 /// (currently infallible placeholder).
147 pub fn upsert_repo(&mut self, repo: WorkspaceRepository) -> WorkspaceResult<()> {
148 let id = repo.id.clone();
149
150 if let Some(existing) = self
151 .repositories
152 .iter_mut()
153 .find(|existing| existing.id == id)
154 {
155 *existing = repo;
156 } else {
157 self.repositories.push(repo);
158 }
159
160 self.metadata.touch_updated();
161 Ok(())
162 }
163
164 /// Remove a source-root repository by id. Returns `true` if an entry
165 /// was removed.
166 pub fn remove_repo(&mut self, repo_id: &WorkspaceRepoId) -> bool {
167 let len_before = self.repositories.len();
168 self.repositories.retain(|repo| repo.id != *repo_id);
169 let removed = self.repositories.len() != len_before;
170
171 if removed {
172 self.metadata.touch_updated();
173 }
174
175 removed
176 }
177
178 fn sort_repositories(&mut self) {
179 self.repositories.sort_by(|a, b| a.id.cmp(&b.id));
180 self.member_folders.sort_by(|a, b| a.id.cmp(&b.id));
181 self.exclusions.sort();
182 }
183
184 /// Returns an ordered map keyed by repo id (primarily for testing/introspection).
185 #[must_use]
186 pub fn as_map(&self) -> BTreeMap<&WorkspaceRepoId, &WorkspaceRepository> {
187 self.repositories
188 .iter()
189 .map(|repo| (&repo.id, repo))
190 .collect()
191 }
192}
193
194/// Registry metadata including versioning and timestamps.
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct WorkspaceMetadata {
197 /// Registry schema version.
198 pub version: u32,
199 /// Optional human-friendly name for the workspace.
200 pub workspace_name: Option<String>,
201 /// Preferred discovery mode for scans (`index-files`, `git-roots`, etc.).
202 #[serde(default)]
203 pub default_discovery_mode: Option<String>,
204 /// Timestamp when the registry was created.
205 #[serde(with = "serde_time")]
206 pub created_at: SystemTime,
207 /// Timestamp when the registry was last updated.
208 #[serde(with = "serde_time")]
209 pub updated_at: SystemTime,
210}
211
212impl WorkspaceMetadata {
213 fn new(workspace_name: Option<String>) -> Self {
214 let now = SystemTime::now();
215 Self {
216 version: WORKSPACE_REGISTRY_VERSION,
217 workspace_name,
218 default_discovery_mode: None,
219 created_at: now,
220 updated_at: now,
221 }
222 }
223
224 fn touch_updated(&mut self) {
225 self.updated_at = SystemTime::now();
226 }
227}
228
229/// Identifier for registered repositories (workspace-relative path).
230#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
231pub struct WorkspaceRepoId(String);
232
233impl WorkspaceRepoId {
234 /// Create an identifier from a workspace-relative path.
235 pub fn new(relative: impl AsRef<Path>) -> WorkspaceRepoId {
236 let path = relative.as_ref();
237
238 let normalized = if path.components().count() == 0 {
239 ".".to_string()
240 } else {
241 path.components()
242 .map(|component| component.as_os_str().to_string_lossy())
243 .collect::<Vec<_>>()
244 .join("/")
245 };
246
247 WorkspaceRepoId(normalized)
248 }
249
250 /// Access the underlying identifier as str.
251 #[must_use]
252 pub fn as_str(&self) -> &str {
253 &self.0
254 }
255}
256
257impl std::fmt::Display for WorkspaceRepoId {
258 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259 write!(f, "{}", self.0)
260 }
261}
262
263/// Repository entry stored in the workspace registry.
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct WorkspaceRepository {
266 /// Stable identifier (workspace-relative path).
267 pub id: WorkspaceRepoId,
268 /// Friendly display name.
269 pub name: String,
270 /// Absolute path to repository root.
271 pub root: PathBuf,
272 /// Path to serialized index data.
273 pub index_path: PathBuf,
274 /// Optional timestamp when the index was most recently updated.
275 #[serde(with = "serde_time::option")]
276 pub last_indexed_at: Option<SystemTime>,
277 /// Symbol count observed the last time this repository was
278 /// registered (`discover_repositories` / `sqry workspace add`),
279 /// read once from `.sqry/graph/manifest.json` at that moment via
280 /// [`WorkspaceRepository::symbol_count_from_manifest`].
281 ///
282 /// This is a point-in-time snapshot, not a live value: it goes
283 /// stale the moment the member is reindexed directly (`sqry index
284 /// --force`) without a matching `workspace remove` + `workspace
285 /// add` cycle. `sqry workspace stats`
286 /// ([`super::stats::DetailedWorkspaceStats::from_registry`]) does
287 /// **not** read this field for that reason; it re-reads each
288 /// member's manifest live at stats-computation time instead. This
289 /// field is kept for [`super::index::WorkspaceIndex::stats`] (the
290 /// coarse, non-detailed stats API) and for any display surface that
291 /// wants a cheap last-known count without touching the filesystem
292 /// again. On disk the JSON key stays `symbol_count` for backward
293 /// compatibility with existing `.sqry-workspace` registry files.
294 #[serde(rename = "symbol_count")]
295 pub symbol_count_at_registration: Option<u64>,
296 /// Optional primary language for the repository.
297 pub primary_language: Option<String>,
298}
299
300impl WorkspaceRepository {
301 /// Create a repository entry with default metadata placeholders.
302 #[must_use]
303 pub fn new(
304 id: WorkspaceRepoId,
305 name: String,
306 root: PathBuf,
307 index_path: PathBuf,
308 last_indexed_at: Option<SystemTime>,
309 ) -> Self {
310 Self {
311 id,
312 name,
313 root,
314 index_path,
315 last_indexed_at,
316 symbol_count_at_registration: None,
317 primary_language: None,
318 }
319 }
320
321 /// Reads the symbol count for `repo_root` from its
322 /// `.sqry/graph/manifest.json` sidecar.
323 ///
324 /// This is the same cheap per-repo metadata file (no full graph
325 /// snapshot load) that `sqry graph status --json` surfaces as its
326 /// `symbol_count` field (mapped from `Manifest::node_count`, see
327 /// [`crate::graph::unified::persistence::manifest::Manifest`]).
328 /// Two distinct call sites use it:
329 ///
330 /// - `discover_repositories` and `sqry workspace add` call it once,
331 /// at registration time, to populate
332 /// [`WorkspaceRepository::symbol_count_at_registration`] (a
333 /// last-known snapshot).
334 /// - [`super::stats::DetailedWorkspaceStats::from_registry`] calls
335 /// it again, live, for every indexed member each time `sqry
336 /// workspace stats` runs, so a direct `sqry index --force`
337 /// reindex is reflected immediately without requiring a
338 /// `workspace remove` + `workspace add` round-trip to refresh the
339 /// registration-time cache.
340 ///
341 /// Returns `None` when the manifest is missing (race with an
342 /// in-progress build) or corrupt/unreadable. Callers must not treat
343 /// `None` as "zero symbols": [`super::stats::DetailedWorkspaceStats`]
344 /// tracks unreadable manifests separately via
345 /// `unknown_symbol_count_repos`, so aggregate totals are never
346 /// silently understated.
347 #[must_use]
348 pub fn symbol_count_from_manifest(repo_root: &Path) -> Option<u64> {
349 match GraphStorage::new(repo_root).try_load_manifest() {
350 ManifestCheck::Present(manifest) => {
351 Some(u64::try_from(manifest.node_count).unwrap_or(u64::MAX))
352 }
353 ManifestCheck::Missing => None,
354 ManifestCheck::Corrupt(err) => {
355 log::warn!(
356 "workspace: manifest.json for {} is corrupt or unreadable, symbol count unknown: {err}",
357 repo_root.display()
358 );
359 None
360 }
361 }
362 }
363}
364
365/// Persisted member-folder entry. Mirrors
366/// [`super::logical::MemberFolder`] but is keyed by a stable
367/// workspace-relative identifier so it round-trips through the registry.
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369pub struct WorkspaceMemberFolder {
370 /// Stable identifier (workspace-relative path).
371 pub id: WorkspaceRepoId,
372 /// Absolute path to the member folder root.
373 pub root: PathBuf,
374 /// Why the folder was classified as a member.
375 pub reason: MemberReason,
376}
377
378impl WorkspaceMemberFolder {
379 /// Create a member folder entry.
380 #[must_use]
381 pub fn new(id: WorkspaceRepoId, root: PathBuf, reason: MemberReason) -> Self {
382 Self { id, root, reason }
383 }
384}