Skip to main content

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}