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