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::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}