spreadsheet_mcp/
state.rs

1use crate::config::ServerConfig;
2#[cfg(feature = "recalc")]
3use crate::fork::{ForkConfig, ForkRegistry};
4use crate::model::{WorkbookId, WorkbookListResponse};
5#[cfg(feature = "recalc")]
6use crate::recalc::{
7    GlobalRecalcLock, LibreOfficeBackend, RecalcBackend, RecalcConfig, create_executor,
8};
9use crate::tools::filters::WorkbookFilter;
10use crate::utils::{hash_path_metadata, make_short_workbook_id};
11use crate::workbook::{WorkbookContext, build_workbook_list};
12use anyhow::{Result, anyhow};
13use lru::LruCache;
14use parking_lot::RwLock;
15use std::collections::HashMap;
16use std::fs;
17use std::num::NonZeroUsize;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use tokio::task;
21use walkdir::WalkDir;
22
23pub struct AppState {
24    config: Arc<ServerConfig>,
25    cache: RwLock<LruCache<WorkbookId, Arc<WorkbookContext>>>,
26    index: RwLock<HashMap<WorkbookId, PathBuf>>,
27    alias_index: RwLock<HashMap<String, WorkbookId>>,
28    #[cfg(feature = "recalc")]
29    fork_registry: Option<Arc<ForkRegistry>>,
30    #[cfg(feature = "recalc")]
31    recalc_backend: Option<Arc<dyn RecalcBackend>>,
32    #[cfg(feature = "recalc")]
33    recalc_semaphore: Option<GlobalRecalcLock>,
34}
35
36impl AppState {
37    pub fn new(config: Arc<ServerConfig>) -> Self {
38        let capacity = NonZeroUsize::new(config.cache_capacity.max(1)).unwrap();
39
40        #[cfg(feature = "recalc")]
41        let (fork_registry, recalc_backend, recalc_semaphore) = if config.recalc_enabled {
42            let fork_config = ForkConfig::default();
43            let registry = ForkRegistry::new(fork_config)
44                .map(Arc::new)
45                .map_err(|e| tracing::warn!("failed to init fork registry: {}", e))
46                .ok();
47
48            if let Some(registry) = &registry {
49                registry.clone().start_cleanup_task();
50            }
51
52            let executor = create_executor(&RecalcConfig::default());
53            let backend: Arc<dyn RecalcBackend> = Arc::new(LibreOfficeBackend::new(executor));
54            let backend = if backend.is_available() {
55                Some(backend)
56            } else {
57                tracing::warn!("recalc backend not available (soffice not found)");
58                None
59            };
60
61            let semaphore = GlobalRecalcLock::new(config.max_concurrent_recalcs);
62
63            (registry, backend, Some(semaphore))
64        } else {
65            (None, None, None)
66        };
67
68        Self {
69            config,
70            cache: RwLock::new(LruCache::new(capacity)),
71            index: RwLock::new(HashMap::new()),
72            alias_index: RwLock::new(HashMap::new()),
73            #[cfg(feature = "recalc")]
74            fork_registry,
75            #[cfg(feature = "recalc")]
76            recalc_backend,
77            #[cfg(feature = "recalc")]
78            recalc_semaphore,
79        }
80    }
81
82    pub fn config(&self) -> Arc<ServerConfig> {
83        self.config.clone()
84    }
85
86    #[cfg(feature = "recalc")]
87    pub fn fork_registry(&self) -> Option<&Arc<ForkRegistry>> {
88        self.fork_registry.as_ref()
89    }
90
91    #[cfg(feature = "recalc")]
92    pub fn recalc_backend(&self) -> Option<&Arc<dyn RecalcBackend>> {
93        self.recalc_backend.as_ref()
94    }
95
96    #[cfg(feature = "recalc")]
97    pub fn recalc_semaphore(&self) -> Option<&GlobalRecalcLock> {
98        self.recalc_semaphore.as_ref()
99    }
100
101    pub fn list_workbooks(&self, filter: WorkbookFilter) -> Result<WorkbookListResponse> {
102        let response = build_workbook_list(&self.config, &filter)?;
103        {
104            let mut index = self.index.write();
105            let mut aliases = self.alias_index.write();
106            for descriptor in &response.workbooks {
107                let abs_path = self.config.resolve_path(PathBuf::from(&descriptor.path));
108                index.insert(descriptor.workbook_id.clone(), abs_path);
109                aliases.insert(
110                    descriptor.short_id.to_ascii_lowercase(),
111                    descriptor.workbook_id.clone(),
112                );
113            }
114        }
115        Ok(response)
116    }
117
118    pub async fn open_workbook(&self, workbook_id: &WorkbookId) -> Result<Arc<WorkbookContext>> {
119        let canonical = self.canonicalize_workbook_id(workbook_id)?;
120        {
121            let mut cache = self.cache.write();
122            if let Some(entry) = cache.get(&canonical) {
123                return Ok(entry.clone());
124            }
125        }
126
127        let path = self.resolve_workbook_path(&canonical)?;
128        let config = self.config.clone();
129        let path_buf = path.clone();
130        let workbook_id_clone = canonical.clone();
131        let workbook =
132            task::spawn_blocking(move || WorkbookContext::load(&config, &path_buf)).await??;
133        let workbook = Arc::new(workbook);
134
135        {
136            let mut aliases = self.alias_index.write();
137            aliases.insert(
138                workbook.short_id.to_ascii_lowercase(),
139                workbook_id_clone.clone(),
140            );
141        }
142
143        let mut cache = self.cache.write();
144        cache.put(workbook_id_clone, workbook.clone());
145        Ok(workbook)
146    }
147
148    pub fn close_workbook(&self, workbook_id: &WorkbookId) -> Result<()> {
149        let canonical = self.canonicalize_workbook_id(workbook_id)?;
150        let mut cache = self.cache.write();
151        cache.pop(&canonical);
152        Ok(())
153    }
154
155    pub fn evict_by_path(&self, path: &Path) {
156        let index = self.index.read();
157        let workbook_id = index
158            .iter()
159            .find(|(_, p)| *p == path)
160            .map(|(id, _)| id.clone());
161        drop(index);
162
163        if let Some(id) = workbook_id {
164            let mut cache = self.cache.write();
165            cache.pop(&id);
166        }
167    }
168
169    fn resolve_workbook_path(&self, workbook_id: &WorkbookId) -> Result<PathBuf> {
170        #[cfg(feature = "recalc")]
171        if let Some(registry) = &self.fork_registry
172            && let Some(fork_path) = registry.get_fork_path(workbook_id.as_str())
173        {
174            return Ok(fork_path);
175        }
176
177        if let Some(path) = self.index.read().get(workbook_id).cloned() {
178            return Ok(path);
179        }
180
181        let located = self.scan_for_workbook(workbook_id.as_str())?;
182        self.register_location(&located);
183        Ok(located.path)
184    }
185
186    fn canonicalize_workbook_id(&self, workbook_id: &WorkbookId) -> Result<WorkbookId> {
187        #[cfg(feature = "recalc")]
188        if let Some(registry) = &self.fork_registry
189            && registry.get_fork_path(workbook_id.as_str()).is_some()
190        {
191            return Ok(workbook_id.clone());
192        }
193
194        if self.index.read().contains_key(workbook_id) {
195            return Ok(workbook_id.clone());
196        }
197        let aliases = self.alias_index.read();
198        if let Some(mapped) = aliases.get(workbook_id.as_str()).cloned() {
199            return Ok(mapped);
200        }
201        let lowered = workbook_id.as_str().to_ascii_lowercase();
202        if lowered != workbook_id.as_str()
203            && let Some(mapped) = aliases.get(&lowered).cloned()
204        {
205            return Ok(mapped);
206        }
207
208        let located = self.scan_for_workbook(workbook_id.as_str())?;
209        let canonical = located.workbook_id.clone();
210        self.register_location(&located);
211        Ok(canonical)
212    }
213
214    fn scan_for_workbook(&self, candidate: &str) -> Result<LocatedWorkbook> {
215        let candidate_lower = candidate.to_ascii_lowercase();
216
217        if let Some(single) = self.config.single_workbook() {
218            let metadata = fs::metadata(single)?;
219            let canonical = WorkbookId(hash_path_metadata(single, &metadata));
220            let slug = single
221                .file_stem()
222                .map(|s| s.to_string_lossy().to_string())
223                .unwrap_or_else(|| "workbook".to_string());
224            let short_id = make_short_workbook_id(&slug, canonical.as_str());
225            if candidate == canonical.as_str() || candidate_lower == short_id {
226                return Ok(LocatedWorkbook {
227                    workbook_id: canonical,
228                    short_id,
229                    path: single.to_path_buf(),
230                });
231            }
232            return Err(anyhow!(
233                "workbook id {} not found in single-workbook mode (expected {} or {})",
234                candidate,
235                canonical.as_str(),
236                short_id
237            ));
238        }
239
240        for entry in WalkDir::new(&self.config.workspace_root) {
241            let entry = entry?;
242            if !entry.file_type().is_file() {
243                continue;
244            }
245            let path = entry.path();
246            if !has_supported_extension(&self.config.supported_extensions, path) {
247                continue;
248            }
249            let metadata = entry.metadata()?;
250            let canonical = WorkbookId(hash_path_metadata(path, &metadata));
251            let slug = path
252                .file_stem()
253                .map(|s| s.to_string_lossy().to_string())
254                .unwrap_or_else(|| "workbook".to_string());
255            let short_id = make_short_workbook_id(&slug, canonical.as_str());
256            if candidate == canonical.as_str() || candidate_lower == short_id {
257                return Ok(LocatedWorkbook {
258                    workbook_id: canonical,
259                    short_id,
260                    path: path.to_path_buf(),
261                });
262            }
263        }
264        Err(anyhow!("workbook id {} not found", candidate))
265    }
266
267    fn register_location(&self, located: &LocatedWorkbook) {
268        self.index
269            .write()
270            .insert(located.workbook_id.clone(), located.path.clone());
271        self.alias_index.write().insert(
272            located.short_id.to_ascii_lowercase(),
273            located.workbook_id.clone(),
274        );
275    }
276}
277
278struct LocatedWorkbook {
279    workbook_id: WorkbookId,
280    short_id: String,
281    path: PathBuf,
282}
283
284fn has_supported_extension(allowed: &[String], path: &Path) -> bool {
285    path.extension()
286        .and_then(|ext| ext.to_str())
287        .map(|ext| {
288            let lower = ext.to_ascii_lowercase();
289            allowed.iter().any(|candidate| candidate == &lower)
290        })
291        .unwrap_or(false)
292}