Skip to main content

spreadsheet_mcp/repository/
path_workspace.rs

1use 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}