Skip to main content

spreadsheet_mcp/
state.rs

1#[cfg(feature = "recalc")]
2use crate::config::RecalcBackendKind;
3use crate::config::ServerConfig;
4#[cfg(feature = "recalc")]
5use crate::fork::{ForkConfig, ForkRegistry};
6use crate::model::{WorkbookId, WorkbookListResponse};
7#[cfg(feature = "recalc-formualizer")]
8use crate::recalc::FormualizerBackend;
9#[cfg(feature = "recalc")]
10use crate::recalc::{GlobalRecalcLock, GlobalScreenshotLock, RecalcBackend};
11#[cfg(feature = "recalc-libreoffice")]
12use crate::recalc::{LibreOfficeBackend, RecalcConfig};
13use crate::repository::{PathWorkspaceRepository, WorkbookRepository};
14use crate::tools::filters::WorkbookFilter;
15use crate::workbook::WorkbookContext;
16use anyhow::Result;
17use lru::LruCache;
18use parking_lot::RwLock;
19use std::num::NonZeroUsize;
20use std::path::Path;
21use std::sync::Arc;
22use tokio::task;
23
24pub struct AppState {
25    config: Arc<ServerConfig>,
26    repository: Arc<dyn WorkbookRepository>,
27    cache: RwLock<LruCache<WorkbookId, Arc<WorkbookContext>>>,
28    #[cfg(feature = "recalc")]
29    fork_registry: Option<Arc<ForkRegistry>>,
30    #[cfg(feature = "recalc")]
31    recalc_backend_preference: RecalcBackendKind,
32    #[cfg(feature = "recalc")]
33    formualizer_backend: Option<Arc<dyn RecalcBackend>>,
34    #[cfg(feature = "recalc")]
35    libreoffice_backend: Option<Arc<dyn RecalcBackend>>,
36    #[cfg(feature = "recalc")]
37    recalc_semaphore: Option<GlobalRecalcLock>,
38    #[cfg(feature = "recalc")]
39    screenshot_semaphore: Option<GlobalScreenshotLock>,
40}
41
42impl AppState {
43    pub fn new(config: Arc<ServerConfig>) -> Self {
44        #[cfg(feature = "recalc")]
45        let components = init_recalc_components(&config);
46
47        #[cfg(feature = "recalc")]
48        let repository: Arc<dyn WorkbookRepository> = Arc::new(PathWorkspaceRepository::new(
49            config.clone(),
50            components.fork_registry.clone(),
51        ));
52
53        #[cfg(not(feature = "recalc"))]
54        let repository: Arc<dyn WorkbookRepository> =
55            Arc::new(PathWorkspaceRepository::new(config.clone()));
56
57        let capacity = NonZeroUsize::new(config.cache_capacity.max(1)).unwrap();
58
59        Self {
60            config,
61            repository,
62            cache: RwLock::new(LruCache::new(capacity)),
63            #[cfg(feature = "recalc")]
64            fork_registry: components.fork_registry,
65            #[cfg(feature = "recalc")]
66            recalc_backend_preference: components.recalc_backend_preference,
67            #[cfg(feature = "recalc")]
68            formualizer_backend: components.formualizer_backend,
69            #[cfg(feature = "recalc")]
70            libreoffice_backend: components.libreoffice_backend,
71            #[cfg(feature = "recalc")]
72            recalc_semaphore: components.recalc_semaphore,
73            #[cfg(feature = "recalc")]
74            screenshot_semaphore: components.screenshot_semaphore,
75        }
76    }
77
78    pub fn new_with_repository(
79        config: Arc<ServerConfig>,
80        repository: Arc<dyn WorkbookRepository>,
81    ) -> Self {
82        let capacity = NonZeroUsize::new(config.cache_capacity.max(1)).unwrap();
83
84        #[cfg(feature = "recalc")]
85        let components = init_recalc_components(&config);
86
87        Self {
88            config,
89            repository,
90            cache: RwLock::new(LruCache::new(capacity)),
91            #[cfg(feature = "recalc")]
92            fork_registry: components.fork_registry,
93            #[cfg(feature = "recalc")]
94            recalc_backend_preference: components.recalc_backend_preference,
95            #[cfg(feature = "recalc")]
96            formualizer_backend: components.formualizer_backend,
97            #[cfg(feature = "recalc")]
98            libreoffice_backend: components.libreoffice_backend,
99            #[cfg(feature = "recalc")]
100            recalc_semaphore: components.recalc_semaphore,
101            #[cfg(feature = "recalc")]
102            screenshot_semaphore: components.screenshot_semaphore,
103        }
104    }
105
106    pub fn config(&self) -> Arc<ServerConfig> {
107        self.config.clone()
108    }
109
110    #[cfg(feature = "recalc")]
111    pub fn fork_registry(&self) -> Option<&Arc<ForkRegistry>> {
112        self.fork_registry.as_ref()
113    }
114
115    #[cfg(feature = "recalc")]
116    pub fn recalc_backend(
117        &self,
118        requested: Option<RecalcBackendKind>,
119    ) -> Option<Arc<dyn RecalcBackend>> {
120        let effective = requested.unwrap_or(self.recalc_backend_preference);
121        match effective {
122            RecalcBackendKind::Formualizer => self.formualizer_backend.clone(),
123            RecalcBackendKind::Libreoffice => self.libreoffice_backend.clone(),
124            RecalcBackendKind::Auto => self
125                .formualizer_backend
126                .clone()
127                .or_else(|| self.libreoffice_backend.clone()),
128        }
129    }
130
131    #[cfg(feature = "recalc")]
132    pub fn recalc_semaphore(&self) -> Option<&GlobalRecalcLock> {
133        self.recalc_semaphore.as_ref()
134    }
135
136    #[cfg(feature = "recalc")]
137    pub fn screenshot_semaphore(&self) -> Option<&GlobalScreenshotLock> {
138        self.screenshot_semaphore.as_ref()
139    }
140
141    pub fn list_workbooks(&self, filter: WorkbookFilter) -> Result<WorkbookListResponse> {
142        self.repository.list(&filter)
143    }
144
145    pub async fn open_workbook(&self, workbook_id: &WorkbookId) -> Result<Arc<WorkbookContext>> {
146        let resolved = self.repository.resolve(workbook_id)?;
147        let canonical = resolved.workbook_id.clone();
148        {
149            let mut cache = self.cache.write();
150            if let Some(entry) = cache.get(&canonical) {
151                return Ok(entry.clone());
152            }
153        }
154
155        let repo = self.repository.clone();
156        let workbook = task::spawn_blocking(move || repo.load_context(&resolved)).await??;
157        let workbook = Arc::new(workbook);
158
159        let mut cache = self.cache.write();
160        cache.put(canonical, workbook.clone());
161        Ok(workbook)
162    }
163
164    pub fn close_workbook(&self, workbook_id: &WorkbookId) -> Result<()> {
165        let canonical = self.repository.resolve(workbook_id)?.workbook_id;
166        let mut cache = self.cache.write();
167        cache.pop(&canonical);
168        Ok(())
169    }
170
171    pub fn evict_by_path(&self, path: &Path) {
172        let evict_ids: Vec<WorkbookId> = self
173            .cache
174            .read()
175            .iter()
176            .filter_map(|(id, ctx)| {
177                if ctx.path == path {
178                    Some(id.clone())
179                } else {
180                    None
181                }
182            })
183            .collect();
184
185        if evict_ids.is_empty() {
186            return;
187        }
188
189        let mut cache = self.cache.write();
190        for id in evict_ids {
191            cache.pop(&id);
192        }
193    }
194}
195
196#[cfg(feature = "recalc")]
197struct RecalcComponents {
198    fork_registry: Option<Arc<ForkRegistry>>,
199    recalc_backend_preference: RecalcBackendKind,
200    formualizer_backend: Option<Arc<dyn RecalcBackend>>,
201    libreoffice_backend: Option<Arc<dyn RecalcBackend>>,
202    recalc_semaphore: Option<GlobalRecalcLock>,
203    screenshot_semaphore: Option<GlobalScreenshotLock>,
204}
205
206#[cfg(feature = "recalc")]
207fn init_recalc_components(config: &Arc<ServerConfig>) -> RecalcComponents {
208    if !config.recalc_enabled {
209        return RecalcComponents {
210            fork_registry: None,
211            recalc_backend_preference: config.recalc_backend,
212            formualizer_backend: None,
213            libreoffice_backend: None,
214            recalc_semaphore: None,
215            screenshot_semaphore: None,
216        };
217    }
218
219    let fork_config = ForkConfig::default();
220    let registry = ForkRegistry::new(fork_config)
221        .map(Arc::new)
222        .map_err(|e| tracing::warn!("failed to init fork registry: {}", e))
223        .ok();
224
225    if let Some(registry) = &registry {
226        registry.clone().start_cleanup_task();
227    }
228
229    #[cfg(feature = "recalc-formualizer")]
230    let formualizer_backend: Option<Arc<dyn RecalcBackend>> = Some(Arc::new(FormualizerBackend));
231    #[cfg(not(feature = "recalc-formualizer"))]
232    let formualizer_backend: Option<Arc<dyn RecalcBackend>> = None;
233
234    #[cfg(feature = "recalc-libreoffice")]
235    let libreoffice_backend: Option<Arc<dyn RecalcBackend>> = {
236        let backend: Arc<dyn RecalcBackend> =
237            Arc::new(LibreOfficeBackend::new(RecalcConfig::default()));
238        if backend.is_available() {
239            Some(backend)
240        } else {
241            tracing::warn!("libreoffice backend not available (soffice not found)");
242            None
243        }
244    };
245    #[cfg(not(feature = "recalc-libreoffice"))]
246    let libreoffice_backend: Option<Arc<dyn RecalcBackend>> = None;
247
248    let selected = match config.recalc_backend {
249        RecalcBackendKind::Auto => formualizer_backend
250            .as_ref()
251            .or(libreoffice_backend.as_ref())
252            .map(|backend| backend.name()),
253        RecalcBackendKind::Formualizer => {
254            formualizer_backend.as_ref().map(|backend| backend.name())
255        }
256        RecalcBackendKind::Libreoffice => {
257            libreoffice_backend.as_ref().map(|backend| backend.name())
258        }
259    };
260
261    if selected.is_none() {
262        tracing::warn!(
263            preferred = ?config.recalc_backend,
264            "recalc backend not available for current build/runtime"
265        );
266    }
267
268    let semaphore = GlobalRecalcLock::new(config.max_concurrent_recalcs);
269    let screenshot_semaphore = libreoffice_backend
270        .as_ref()
271        .map(|_| GlobalScreenshotLock::new());
272
273    RecalcComponents {
274        fork_registry: registry,
275        recalc_backend_preference: config.recalc_backend,
276        formualizer_backend,
277        libreoffice_backend,
278        recalc_semaphore: Some(semaphore),
279        screenshot_semaphore,
280    }
281}