Skip to main content

sqry_core/workspace/
registry.rs

1//! Workspace registry data structures and persistence helpers.
2
3use std::collections::BTreeMap;
4use std::fs::{self, File};
5use std::path::{Path, PathBuf};
6use std::time::SystemTime;
7
8use serde::{Deserialize, Serialize};
9
10use super::error::{WorkspaceError, WorkspaceResult};
11use super::serde_time;
12
13/// Current on-disk registry format version.
14pub const WORKSPACE_REGISTRY_VERSION: u32 = 1;
15
16/// Serializable workspace registry stored in `.sqry-workspace`.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct WorkspaceRegistry {
19    /// Registry metadata (versioning, timestamps).
20    pub metadata: WorkspaceMetadata,
21    /// Registered repositories.
22    #[serde(default)]
23    pub repositories: Vec<WorkspaceRepository>,
24}
25
26impl WorkspaceRegistry {
27    /// Construct a new empty registry.
28    #[must_use]
29    pub fn new(workspace_name: Option<String>) -> Self {
30        Self {
31            metadata: WorkspaceMetadata::new(workspace_name),
32            repositories: Vec::new(),
33        }
34    }
35
36    /// Load a registry from `path`.
37    ///
38    /// # Errors
39    ///
40    /// Returns [`WorkspaceError`] when the file cannot be read, parsed, or when the version is unsupported.
41    pub fn load(path: &Path) -> WorkspaceResult<Self> {
42        let file = File::open(path).map_err(|err| WorkspaceError::io(path, err))?;
43        let mut registry: WorkspaceRegistry =
44            serde_json::from_reader(file).map_err(WorkspaceError::Serialization)?;
45
46        if registry.metadata.version != WORKSPACE_REGISTRY_VERSION {
47            return Err(WorkspaceError::UnsupportedVersion {
48                found: registry.metadata.version,
49                expected: WORKSPACE_REGISTRY_VERSION,
50            });
51        }
52
53        registry.sort_repositories();
54        Ok(registry)
55    }
56
57    /// Persist the registry to `path`, creating parent directories if necessary.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`WorkspaceError`] when directories cannot be created or serialization fails.
62    pub fn save(&mut self, path: &Path) -> WorkspaceResult<()> {
63        if let Some(parent) = path.parent() {
64            fs::create_dir_all(parent).map_err(|err| WorkspaceError::io(parent, err))?;
65        }
66
67        self.sort_repositories();
68        self.metadata.touch_updated();
69
70        let file = File::create(path).map_err(|err| WorkspaceError::io(path, err))?;
71        serde_json::to_writer_pretty(file, self).map_err(WorkspaceError::Serialization)
72    }
73
74    /// Insert or update a repository entry.
75    ///
76    /// # Errors
77    ///
78    /// Returns [`WorkspaceError`] when persistence metadata updates fail (currently infallible placeholder).
79    pub fn upsert_repo(&mut self, repo: WorkspaceRepository) -> WorkspaceResult<()> {
80        let id = repo.id.clone();
81
82        if let Some(existing) = self
83            .repositories
84            .iter_mut()
85            .find(|existing| existing.id == id)
86        {
87            *existing = repo;
88        } else {
89            self.repositories.push(repo);
90        }
91
92        self.metadata.touch_updated();
93        Ok(())
94    }
95
96    /// Remove a repository by id. Returns `true` if an entry was removed.
97    pub fn remove_repo(&mut self, repo_id: &WorkspaceRepoId) -> bool {
98        let len_before = self.repositories.len();
99        self.repositories.retain(|repo| repo.id != *repo_id);
100        let removed = self.repositories.len() != len_before;
101
102        if removed {
103            self.metadata.touch_updated();
104        }
105
106        removed
107    }
108
109    fn sort_repositories(&mut self) {
110        self.repositories.sort_by(|a, b| a.id.cmp(&b.id));
111    }
112
113    /// Returns an ordered map keyed by repo id (primarily for testing/introspection).
114    #[must_use]
115    pub fn as_map(&self) -> BTreeMap<&WorkspaceRepoId, &WorkspaceRepository> {
116        self.repositories
117            .iter()
118            .map(|repo| (&repo.id, repo))
119            .collect()
120    }
121}
122
123/// Registry metadata including versioning and timestamps.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct WorkspaceMetadata {
126    /// Registry schema version.
127    pub version: u32,
128    /// Optional human-friendly name for the workspace.
129    pub workspace_name: Option<String>,
130    /// Preferred discovery mode for scans (`index-files`, `git-roots`, etc.).
131    #[serde(default)]
132    pub default_discovery_mode: Option<String>,
133    /// Timestamp when the registry was created.
134    #[serde(with = "serde_time")]
135    pub created_at: SystemTime,
136    /// Timestamp when the registry was last updated.
137    #[serde(with = "serde_time")]
138    pub updated_at: SystemTime,
139}
140
141impl WorkspaceMetadata {
142    fn new(workspace_name: Option<String>) -> Self {
143        let now = SystemTime::now();
144        Self {
145            version: WORKSPACE_REGISTRY_VERSION,
146            workspace_name,
147            default_discovery_mode: None,
148            created_at: now,
149            updated_at: now,
150        }
151    }
152
153    fn touch_updated(&mut self) {
154        self.updated_at = SystemTime::now();
155    }
156}
157
158/// Identifier for registered repositories (workspace-relative path).
159#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
160pub struct WorkspaceRepoId(String);
161
162impl WorkspaceRepoId {
163    /// Create an identifier from a workspace-relative path.
164    pub fn new(relative: impl AsRef<Path>) -> WorkspaceRepoId {
165        let path = relative.as_ref();
166
167        let normalized = if path.components().count() == 0 {
168            ".".to_string()
169        } else {
170            path.components()
171                .map(|component| component.as_os_str().to_string_lossy())
172                .collect::<Vec<_>>()
173                .join("/")
174        };
175
176        WorkspaceRepoId(normalized)
177    }
178
179    /// Access the underlying identifier as str.
180    #[must_use]
181    pub fn as_str(&self) -> &str {
182        &self.0
183    }
184}
185
186impl std::fmt::Display for WorkspaceRepoId {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        write!(f, "{}", self.0)
189    }
190}
191
192/// Repository entry stored in the workspace registry.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct WorkspaceRepository {
195    /// Stable identifier (workspace-relative path).
196    pub id: WorkspaceRepoId,
197    /// Friendly display name.
198    pub name: String,
199    /// Absolute path to repository root.
200    pub root: PathBuf,
201    /// Path to serialized index data.
202    pub index_path: PathBuf,
203    /// Optional timestamp when the index was most recently updated.
204    #[serde(with = "serde_time::option")]
205    pub last_indexed_at: Option<SystemTime>,
206    /// Optional cached symbol count.
207    pub symbol_count: Option<u64>,
208    /// Optional primary language for the repository.
209    pub primary_language: Option<String>,
210}
211
212impl WorkspaceRepository {
213    /// Create a repository entry with default metadata placeholders.
214    #[must_use]
215    pub fn new(
216        id: WorkspaceRepoId,
217        name: String,
218        root: PathBuf,
219        index_path: PathBuf,
220        last_indexed_at: Option<SystemTime>,
221    ) -> Self {
222        Self {
223            id,
224            name,
225            root,
226            index_path,
227            last_indexed_at,
228            symbol_count: None,
229            primary_language: None,
230        }
231    }
232}