spreadsheet_read_mcp/
state.rs

1use crate::config::ServerConfig;
2use crate::model::{WorkbookId, WorkbookListResponse};
3use crate::tools::filters::WorkbookFilter;
4use crate::utils::{hash_path_metadata, make_short_workbook_id};
5use crate::workbook::{WorkbookContext, build_workbook_list};
6use anyhow::{Result, anyhow};
7use lru::LruCache;
8use parking_lot::RwLock;
9use std::collections::HashMap;
10use std::fs;
11use std::num::NonZeroUsize;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14use tokio::task;
15use walkdir::WalkDir;
16
17pub struct AppState {
18    config: Arc<ServerConfig>,
19    cache: RwLock<LruCache<WorkbookId, Arc<WorkbookContext>>>,
20    index: RwLock<HashMap<WorkbookId, PathBuf>>,
21    alias_index: RwLock<HashMap<String, WorkbookId>>,
22}
23
24impl AppState {
25    pub fn new(config: Arc<ServerConfig>) -> Self {
26        let capacity = NonZeroUsize::new(config.cache_capacity.max(1)).unwrap();
27        Self {
28            config,
29            cache: RwLock::new(LruCache::new(capacity)),
30            index: RwLock::new(HashMap::new()),
31            alias_index: RwLock::new(HashMap::new()),
32        }
33    }
34
35    pub fn config(&self) -> Arc<ServerConfig> {
36        self.config.clone()
37    }
38
39    pub fn list_workbooks(&self, filter: WorkbookFilter) -> Result<WorkbookListResponse> {
40        let response = build_workbook_list(&self.config, &filter)?;
41        {
42            let mut index = self.index.write();
43            let mut aliases = self.alias_index.write();
44            for descriptor in &response.workbooks {
45                let abs_path = self.config.resolve_path(PathBuf::from(&descriptor.path));
46                index.insert(descriptor.workbook_id.clone(), abs_path);
47                aliases.insert(
48                    descriptor.short_id.to_ascii_lowercase(),
49                    descriptor.workbook_id.clone(),
50                );
51            }
52        }
53        Ok(response)
54    }
55
56    pub async fn open_workbook(&self, workbook_id: &WorkbookId) -> Result<Arc<WorkbookContext>> {
57        let canonical = self.canonicalize_workbook_id(workbook_id)?;
58        {
59            let mut cache = self.cache.write();
60            if let Some(entry) = cache.get(&canonical) {
61                return Ok(entry.clone());
62            }
63        }
64
65        let path = self.resolve_workbook_path(&canonical)?;
66        let config = self.config.clone();
67        let path_buf = path.clone();
68        let workbook_id_clone = canonical.clone();
69        let workbook =
70            task::spawn_blocking(move || WorkbookContext::load(&config, &path_buf)).await??;
71        let workbook = Arc::new(workbook);
72
73        {
74            let mut aliases = self.alias_index.write();
75            aliases.insert(
76                workbook.short_id.to_ascii_lowercase(),
77                workbook_id_clone.clone(),
78            );
79        }
80
81        let mut cache = self.cache.write();
82        cache.put(workbook_id_clone, workbook.clone());
83        Ok(workbook)
84    }
85
86    pub fn close_workbook(&self, workbook_id: &WorkbookId) -> Result<()> {
87        let canonical = self.canonicalize_workbook_id(workbook_id)?;
88        let mut cache = self.cache.write();
89        cache.pop(&canonical);
90        Ok(())
91    }
92
93    fn resolve_workbook_path(&self, workbook_id: &WorkbookId) -> Result<PathBuf> {
94        if let Some(path) = self.index.read().get(workbook_id).cloned() {
95            return Ok(path);
96        }
97
98        let located = self.scan_for_workbook(workbook_id.as_str())?;
99        self.register_location(&located);
100        Ok(located.path)
101    }
102
103    fn canonicalize_workbook_id(&self, workbook_id: &WorkbookId) -> Result<WorkbookId> {
104        if self.index.read().contains_key(workbook_id) {
105            return Ok(workbook_id.clone());
106        }
107        let aliases = self.alias_index.read();
108        if let Some(mapped) = aliases.get(workbook_id.as_str()).cloned() {
109            return Ok(mapped);
110        }
111        let lowered = workbook_id.as_str().to_ascii_lowercase();
112        if lowered != workbook_id.as_str()
113            && let Some(mapped) = aliases.get(&lowered).cloned()
114        {
115            return Ok(mapped);
116        }
117
118        let located = self.scan_for_workbook(workbook_id.as_str())?;
119        let canonical = located.workbook_id.clone();
120        self.register_location(&located);
121        Ok(canonical)
122    }
123
124    fn scan_for_workbook(&self, candidate: &str) -> Result<LocatedWorkbook> {
125        let candidate_lower = candidate.to_ascii_lowercase();
126
127        if let Some(single) = self.config.single_workbook() {
128            let metadata = fs::metadata(single)?;
129            let canonical = WorkbookId(hash_path_metadata(single, &metadata));
130            let slug = single
131                .file_stem()
132                .map(|s| s.to_string_lossy().to_string())
133                .unwrap_or_else(|| "workbook".to_string());
134            let short_id = make_short_workbook_id(&slug, canonical.as_str());
135            if candidate == canonical.as_str() || candidate_lower == short_id {
136                return Ok(LocatedWorkbook {
137                    workbook_id: canonical,
138                    short_id,
139                    path: single.to_path_buf(),
140                });
141            }
142            return Err(anyhow!(
143                "workbook id {} not found in single-workbook mode (expected {} or {})",
144                candidate,
145                canonical.as_str(),
146                short_id
147            ));
148        }
149
150        for entry in WalkDir::new(&self.config.workspace_root) {
151            let entry = entry?;
152            if !entry.file_type().is_file() {
153                continue;
154            }
155            let path = entry.path();
156            if !has_supported_extension(&self.config.supported_extensions, path) {
157                continue;
158            }
159            let metadata = entry.metadata()?;
160            let canonical = WorkbookId(hash_path_metadata(path, &metadata));
161            let slug = path
162                .file_stem()
163                .map(|s| s.to_string_lossy().to_string())
164                .unwrap_or_else(|| "workbook".to_string());
165            let short_id = make_short_workbook_id(&slug, canonical.as_str());
166            if candidate == canonical.as_str() || candidate_lower == short_id {
167                return Ok(LocatedWorkbook {
168                    workbook_id: canonical,
169                    short_id,
170                    path: path.to_path_buf(),
171                });
172            }
173        }
174        Err(anyhow!("workbook id {} not found", candidate))
175    }
176
177    fn register_location(&self, located: &LocatedWorkbook) {
178        self.index
179            .write()
180            .insert(located.workbook_id.clone(), located.path.clone());
181        self.alias_index.write().insert(
182            located.short_id.to_ascii_lowercase(),
183            located.workbook_id.clone(),
184        );
185    }
186}
187
188struct LocatedWorkbook {
189    workbook_id: WorkbookId,
190    short_id: String,
191    path: PathBuf,
192}
193
194fn has_supported_extension(allowed: &[String], path: &Path) -> bool {
195    path.extension()
196        .and_then(|ext| ext.to_str())
197        .map(|ext| {
198            let lower = ext.to_ascii_lowercase();
199            allowed.iter().any(|candidate| candidate == &lower)
200        })
201        .unwrap_or(false)
202}