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::project::types::ProjectRootMode;
33
34/// Current on-disk registry format version.
35pub const WORKSPACE_REGISTRY_VERSION: u32 = 2;
36
37/// Serializable workspace registry stored in `.sqry-workspace`.
38///
39/// On-disk layout corresponds to schema **v2** — see the module-level
40/// docs for the v1 → v2 upgrade contract.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct WorkspaceRegistry {
43 /// Registry metadata (versioning, timestamps).
44 pub metadata: WorkspaceMetadata,
45 /// Auto-indexed source roots. Persisted under the `source_roots`
46 /// JSON key in v2; deserialization also accepts the v1
47 /// `repositories` key (see [`Self::load`]).
48 #[serde(default, rename = "source_roots", alias = "repositories")]
49 pub repositories: Vec<WorkspaceRepository>,
50 /// Workspace member folders — part of the workspace but **not**
51 /// auto-indexed (reads still resolve through the workspace's source
52 /// roots). Empty in v1.
53 #[serde(default)]
54 pub member_folders: Vec<WorkspaceMemberFolder>,
55 /// Explicitly excluded paths (opaque to sqry). Empty in v1.
56 #[serde(default)]
57 pub exclusions: Vec<PathBuf>,
58 /// Workspace-scoped project-root resolution mode.
59 /// Defaults to [`ProjectRootMode::default`] (= `GitRoot`) when absent.
60 #[serde(default)]
61 pub project_root_mode: ProjectRootMode,
62}
63
64impl WorkspaceRegistry {
65 /// Construct a new empty registry at the current schema version.
66 #[must_use]
67 pub fn new(workspace_name: Option<String>) -> Self {
68 Self {
69 metadata: WorkspaceMetadata::new(workspace_name),
70 repositories: Vec::new(),
71 member_folders: Vec::new(),
72 exclusions: Vec::new(),
73 project_root_mode: ProjectRootMode::default(),
74 }
75 }
76
77 /// Load a registry from `path`.
78 ///
79 /// Schema v1 files (single-flat `repositories` array) are auto-upgraded
80 /// to v2 in memory: existing entries become source roots; member-folder,
81 /// exclusion, and project-root-mode fields are initialised to their
82 /// defaults. Files with `metadata.version > 2` are rejected.
83 ///
84 /// # Errors
85 ///
86 /// Returns [`WorkspaceError::Io`] when the file cannot be read,
87 /// [`WorkspaceError::Serialization`] when the JSON is malformed, and
88 /// [`WorkspaceError::UnsupportedVersion`] when the on-disk version is
89 /// newer than this build understands.
90 pub fn load(path: &Path) -> WorkspaceResult<Self> {
91 let file = File::open(path).map_err(|err| WorkspaceError::io(path, err))?;
92 let mut registry: WorkspaceRegistry =
93 serde_json::from_reader(file).map_err(WorkspaceError::Serialization)?;
94
95 match registry.metadata.version {
96 1 => {
97 // v1 → v2 upgrade: keep already-deserialized `repositories`
98 // (the `alias = "repositories"` lets us read the v1 key as
99 // `source_roots`), default the v2-only collections, and
100 // bump the in-memory version. The next `save()` writes v2.
101 registry.member_folders = Vec::new();
102 registry.exclusions = Vec::new();
103 registry.project_root_mode = ProjectRootMode::default();
104 registry.metadata.version = WORKSPACE_REGISTRY_VERSION;
105 }
106 2 => {}
107 other => {
108 return Err(WorkspaceError::UnsupportedVersion {
109 found: other,
110 expected: WORKSPACE_REGISTRY_VERSION,
111 });
112 }
113 }
114
115 registry.sort_repositories();
116 Ok(registry)
117 }
118
119 /// Persist the registry to `path`, creating parent directories if
120 /// necessary. Always writes the current ([`WORKSPACE_REGISTRY_VERSION`])
121 /// schema regardless of how the in-memory representation was loaded.
122 ///
123 /// # Errors
124 ///
125 /// Returns [`WorkspaceError`] when directories cannot be created or
126 /// serialization fails.
127 pub fn save(&mut self, path: &Path) -> WorkspaceResult<()> {
128 if let Some(parent) = path.parent() {
129 fs::create_dir_all(parent).map_err(|err| WorkspaceError::io(parent, err))?;
130 }
131
132 self.sort_repositories();
133 self.metadata.version = WORKSPACE_REGISTRY_VERSION;
134 self.metadata.touch_updated();
135
136 let file = File::create(path).map_err(|err| WorkspaceError::io(path, err))?;
137 serde_json::to_writer_pretty(file, self).map_err(WorkspaceError::Serialization)
138 }
139
140 /// Insert or update a source-root repository entry.
141 ///
142 /// # Errors
143 ///
144 /// Returns [`WorkspaceError`] when persistence metadata updates fail
145 /// (currently infallible placeholder).
146 pub fn upsert_repo(&mut self, repo: WorkspaceRepository) -> WorkspaceResult<()> {
147 let id = repo.id.clone();
148
149 if let Some(existing) = self
150 .repositories
151 .iter_mut()
152 .find(|existing| existing.id == id)
153 {
154 *existing = repo;
155 } else {
156 self.repositories.push(repo);
157 }
158
159 self.metadata.touch_updated();
160 Ok(())
161 }
162
163 /// Remove a source-root repository by id. Returns `true` if an entry
164 /// was removed.
165 pub fn remove_repo(&mut self, repo_id: &WorkspaceRepoId) -> bool {
166 let len_before = self.repositories.len();
167 self.repositories.retain(|repo| repo.id != *repo_id);
168 let removed = self.repositories.len() != len_before;
169
170 if removed {
171 self.metadata.touch_updated();
172 }
173
174 removed
175 }
176
177 fn sort_repositories(&mut self) {
178 self.repositories.sort_by(|a, b| a.id.cmp(&b.id));
179 self.member_folders.sort_by(|a, b| a.id.cmp(&b.id));
180 self.exclusions.sort();
181 }
182
183 /// Returns an ordered map keyed by repo id (primarily for testing/introspection).
184 #[must_use]
185 pub fn as_map(&self) -> BTreeMap<&WorkspaceRepoId, &WorkspaceRepository> {
186 self.repositories
187 .iter()
188 .map(|repo| (&repo.id, repo))
189 .collect()
190 }
191}
192
193/// Registry metadata including versioning and timestamps.
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct WorkspaceMetadata {
196 /// Registry schema version.
197 pub version: u32,
198 /// Optional human-friendly name for the workspace.
199 pub workspace_name: Option<String>,
200 /// Preferred discovery mode for scans (`index-files`, `git-roots`, etc.).
201 #[serde(default)]
202 pub default_discovery_mode: Option<String>,
203 /// Timestamp when the registry was created.
204 #[serde(with = "serde_time")]
205 pub created_at: SystemTime,
206 /// Timestamp when the registry was last updated.
207 #[serde(with = "serde_time")]
208 pub updated_at: SystemTime,
209}
210
211impl WorkspaceMetadata {
212 fn new(workspace_name: Option<String>) -> Self {
213 let now = SystemTime::now();
214 Self {
215 version: WORKSPACE_REGISTRY_VERSION,
216 workspace_name,
217 default_discovery_mode: None,
218 created_at: now,
219 updated_at: now,
220 }
221 }
222
223 fn touch_updated(&mut self) {
224 self.updated_at = SystemTime::now();
225 }
226}
227
228/// Identifier for registered repositories (workspace-relative path).
229#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
230pub struct WorkspaceRepoId(String);
231
232impl WorkspaceRepoId {
233 /// Create an identifier from a workspace-relative path.
234 pub fn new(relative: impl AsRef<Path>) -> WorkspaceRepoId {
235 let path = relative.as_ref();
236
237 let normalized = if path.components().count() == 0 {
238 ".".to_string()
239 } else {
240 path.components()
241 .map(|component| component.as_os_str().to_string_lossy())
242 .collect::<Vec<_>>()
243 .join("/")
244 };
245
246 WorkspaceRepoId(normalized)
247 }
248
249 /// Access the underlying identifier as str.
250 #[must_use]
251 pub fn as_str(&self) -> &str {
252 &self.0
253 }
254}
255
256impl std::fmt::Display for WorkspaceRepoId {
257 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258 write!(f, "{}", self.0)
259 }
260}
261
262/// Repository entry stored in the workspace registry.
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct WorkspaceRepository {
265 /// Stable identifier (workspace-relative path).
266 pub id: WorkspaceRepoId,
267 /// Friendly display name.
268 pub name: String,
269 /// Absolute path to repository root.
270 pub root: PathBuf,
271 /// Path to serialized index data.
272 pub index_path: PathBuf,
273 /// Optional timestamp when the index was most recently updated.
274 #[serde(with = "serde_time::option")]
275 pub last_indexed_at: Option<SystemTime>,
276 /// Optional cached symbol count.
277 pub symbol_count: Option<u64>,
278 /// Optional primary language for the repository.
279 pub primary_language: Option<String>,
280}
281
282impl WorkspaceRepository {
283 /// Create a repository entry with default metadata placeholders.
284 #[must_use]
285 pub fn new(
286 id: WorkspaceRepoId,
287 name: String,
288 root: PathBuf,
289 index_path: PathBuf,
290 last_indexed_at: Option<SystemTime>,
291 ) -> Self {
292 Self {
293 id,
294 name,
295 root,
296 index_path,
297 last_indexed_at,
298 symbol_count: None,
299 primary_language: None,
300 }
301 }
302}
303
304/// Persisted member-folder entry. Mirrors
305/// [`super::logical::MemberFolder`] but is keyed by a stable
306/// workspace-relative identifier so it round-trips through the registry.
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308pub struct WorkspaceMemberFolder {
309 /// Stable identifier (workspace-relative path).
310 pub id: WorkspaceRepoId,
311 /// Absolute path to the member folder root.
312 pub root: PathBuf,
313 /// Why the folder was classified as a member.
314 pub reason: MemberReason,
315}
316
317impl WorkspaceMemberFolder {
318 /// Create a member folder entry.
319 #[must_use]
320 pub fn new(id: WorkspaceRepoId, root: PathBuf, reason: MemberReason) -> Self {
321 Self { id, root, reason }
322 }
323}