spreadsheet_mcp/repository/
path_workspace.rs1use super::{ResolvedWorkbookRef, WorkbookRepository, WorkbookSource};
2use crate::config::ServerConfig;
3#[cfg(feature = "recalc")]
4use crate::fork::ForkRegistry;
5use crate::model::{WorkbookDescriptor, WorkbookId, WorkbookListResponse};
6use crate::tools::filters::WorkbookFilter;
7use crate::utils::{
8 hash_file_sha256_hex, hash_path_identity, hash_path_metadata, make_short_workbook_id,
9 path_to_forward_slashes, system_time_to_rfc3339,
10};
11use crate::workbook::WorkbookContext;
12use anyhow::{Result, anyhow};
13use chrono::SecondsFormat;
14use parking_lot::RwLock;
15use std::collections::HashMap;
16use std::fs;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19use walkdir::WalkDir;
20
21pub struct PathWorkspaceRepository {
22 config: Arc<ServerConfig>,
23 index: RwLock<HashMap<WorkbookId, IndexedWorkbook>>,
24 alias_index: RwLock<HashMap<String, WorkbookId>>,
25 legacy_alias_index: RwLock<HashMap<String, WorkbookId>>,
26 #[cfg(feature = "recalc")]
27 fork_registry: Option<Arc<ForkRegistry>>,
28}
29
30impl PathWorkspaceRepository {
31 #[cfg(feature = "recalc")]
32 pub fn new(config: Arc<ServerConfig>, fork_registry: Option<Arc<ForkRegistry>>) -> Self {
33 Self {
34 config,
35 index: RwLock::new(HashMap::new()),
36 alias_index: RwLock::new(HashMap::new()),
37 legacy_alias_index: RwLock::new(HashMap::new()),
38 fork_registry,
39 }
40 }
41
42 #[cfg(not(feature = "recalc"))]
43 pub fn new(config: Arc<ServerConfig>) -> Self {
44 Self {
45 config,
46 index: RwLock::new(HashMap::new()),
47 alias_index: RwLock::new(HashMap::new()),
48 legacy_alias_index: RwLock::new(HashMap::new()),
49 }
50 }
51
52 fn register(&self, located: &LocatedWorkbook) {
53 self.index.write().insert(
54 located.workbook_id.clone(),
55 IndexedWorkbook {
56 path: located.path.clone(),
57 short_id: located.short_id.clone(),
58 revision_id: located.revision_id.clone(),
59 },
60 );
61
62 let mut aliases = self.alias_index.write();
63 aliases.insert(
64 located.short_id.to_ascii_lowercase(),
65 located.workbook_id.clone(),
66 );
67 aliases.insert(
68 located.workbook_id.as_str().to_ascii_lowercase(),
69 located.workbook_id.clone(),
70 );
71
72 self.legacy_alias_index.write().insert(
73 located.legacy_id.to_ascii_lowercase(),
74 located.workbook_id.clone(),
75 );
76 }
77
78 fn register_all(&self, located: &[LocatedWorkbook]) {
79 for entry in located {
80 self.register(entry);
81 }
82 }
83
84 fn locate_by_path(&self, path: &Path) -> Result<LocatedWorkbook> {
85 let metadata = fs::metadata(path)?;
86 let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
87 let workbook_id = WorkbookId(hash_path_identity(&canonical));
88 let legacy_id = hash_path_metadata(path, &metadata);
89 let slug = path
90 .file_stem()
91 .map(|s| s.to_string_lossy().to_string())
92 .unwrap_or_else(|| "workbook".to_string());
93 let short_id = make_short_workbook_id(&slug, workbook_id.as_str());
94
95 Ok(LocatedWorkbook {
96 workbook_id,
97 short_id,
98 legacy_id,
99 slug,
100 folder: derive_folder(&self.config, path),
101 path: path.to_path_buf(),
102 bytes: metadata.len(),
103 last_modified: metadata
104 .modified()
105 .ok()
106 .and_then(system_time_to_rfc3339)
107 .map(|dt| dt.to_rfc3339_opts(SecondsFormat::Secs, true)),
108 revision_id: Some(hash_file_sha256_hex(path)?),
109 })
110 }
111
112 fn scan_workbooks(&self) -> Result<Vec<LocatedWorkbook>> {
113 let mut out = Vec::new();
114
115 if let Some(single) = self.config.single_workbook() {
116 out.push(self.locate_by_path(single)?);
117 return Ok(out);
118 }
119
120 for entry in WalkDir::new(&self.config.workspace_root) {
121 let entry = entry?;
122 if !entry.file_type().is_file() {
123 continue;
124 }
125 let path = entry.path();
126 if !has_supported_extension(&self.config.supported_extensions, path) {
127 continue;
128 }
129 out.push(self.locate_by_path(path)?);
130 }
131
132 out.sort_by(|a, b| a.slug.cmp(&b.slug));
133 Ok(out)
134 }
135
136 fn lookup_indexed(&self, id_or_alias: &WorkbookId) -> Option<WorkbookId> {
137 if self.index.read().contains_key(id_or_alias) {
138 return Some(id_or_alias.clone());
139 }
140
141 let lowered = id_or_alias.as_str().to_ascii_lowercase();
142 if let Some(id) = self.alias_index.read().get(&lowered).cloned() {
143 return Some(id);
144 }
145 self.legacy_alias_index.read().get(&lowered).cloned()
146 }
147}
148
149impl WorkbookRepository for PathWorkspaceRepository {
150 fn list(&self, filter: &WorkbookFilter) -> Result<WorkbookListResponse> {
151 let located = self.scan_workbooks()?;
152 self.register_all(&located);
153
154 let mut descriptors = Vec::new();
155 for wb in located {
156 if !filter.matches(&wb.slug, wb.folder.as_deref(), &wb.path) {
157 continue;
158 }
159
160 let relative = wb
161 .path
162 .strip_prefix(&self.config.workspace_root)
163 .unwrap_or(&wb.path);
164 descriptors.push(WorkbookDescriptor {
165 workbook_id: wb.workbook_id,
166 short_id: wb.short_id,
167 slug: wb.slug,
168 folder: wb.folder,
169 path: Some(path_to_forward_slashes(relative)),
170 client_path: None,
171 bytes: wb.bytes,
172 last_modified: wb.last_modified,
173 revision_id: wb.revision_id,
174 caps: Some(crate::caps::BackendCaps::xlsx()),
175 });
176 }
177
178 Ok(WorkbookListResponse {
179 workbooks: descriptors,
180 next_offset: None,
181 })
182 }
183
184 fn resolve(&self, id_or_alias: &WorkbookId) -> Result<ResolvedWorkbookRef> {
185 #[cfg(feature = "recalc")]
186 if let Some(registry) = &self.fork_registry
187 && let Some(path) = registry.get_fork_path(id_or_alias.as_str())
188 {
189 return Ok(ResolvedWorkbookRef {
190 workbook_id: id_or_alias.clone(),
191 short_id: make_short_workbook_id("fork", id_or_alias.as_str()),
192 revision_id: Some(hash_file_sha256_hex(&path)?),
193 source: WorkbookSource::Path(path),
194 });
195 }
196
197 if let Some(canonical_id) = self.lookup_indexed(id_or_alias) {
198 let indexed = self.index.read().get(&canonical_id).cloned();
199 if let Some(indexed) = indexed {
200 return Ok(ResolvedWorkbookRef {
201 workbook_id: canonical_id,
202 short_id: indexed.short_id,
203 revision_id: indexed.revision_id,
204 source: WorkbookSource::Path(indexed.path),
205 });
206 }
207 }
208
209 let candidate = id_or_alias.as_str().to_ascii_lowercase();
210 let scanned = self.scan_workbooks()?;
211 self.register_all(&scanned);
212
213 for wb in scanned {
214 if candidate == wb.workbook_id.as_str().to_ascii_lowercase()
215 || candidate == wb.short_id.to_ascii_lowercase()
216 || candidate == wb.legacy_id.to_ascii_lowercase()
217 {
218 return Ok(wb.into_resolved());
219 }
220 }
221
222 Err(anyhow!("workbook id {} not found", id_or_alias.as_str()))
223 }
224
225 fn load_context(&self, resolved: &ResolvedWorkbookRef) -> Result<WorkbookContext> {
226 match &resolved.source {
227 WorkbookSource::Path(path) => WorkbookContext::load_from_path(
228 &self.config,
229 path,
230 resolved.workbook_id.clone(),
231 resolved.short_id.clone(),
232 resolved.revision_id.clone(),
233 ),
234 WorkbookSource::Virtual(id) => Err(anyhow!(
235 "path workspace repository cannot load virtual workbook {id}"
236 )),
237 }
238 }
239}
240
241struct LocatedWorkbook {
242 workbook_id: WorkbookId,
243 short_id: String,
244 legacy_id: String,
245 slug: String,
246 folder: Option<String>,
247 path: PathBuf,
248 bytes: u64,
249 last_modified: Option<String>,
250 revision_id: Option<String>,
251}
252
253impl LocatedWorkbook {
254 fn into_resolved(self) -> ResolvedWorkbookRef {
255 ResolvedWorkbookRef {
256 workbook_id: self.workbook_id,
257 short_id: self.short_id,
258 revision_id: self.revision_id,
259 source: WorkbookSource::Path(self.path),
260 }
261 }
262}
263
264#[derive(Clone)]
265struct IndexedWorkbook {
266 path: PathBuf,
267 short_id: String,
268 revision_id: Option<String>,
269}
270
271fn derive_folder(config: &Arc<ServerConfig>, path: &Path) -> Option<String> {
272 path.strip_prefix(&config.workspace_root)
273 .ok()
274 .and_then(|relative| relative.parent())
275 .and_then(|parent| parent.file_name())
276 .map(|os| os.to_string_lossy().to_string())
277}
278
279fn has_supported_extension(allowed: &[String], path: &Path) -> bool {
280 path.extension()
281 .and_then(|ext| ext.to_str())
282 .map(|ext| {
283 let lower = ext.to_ascii_lowercase();
284 allowed.iter().any(|candidate| candidate == &lower)
285 })
286 .unwrap_or(false)
287}