spreadsheet_read_mcp/
state.rs1use 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}