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 return Ok(mapped);
115 }
116
117 let located = self.scan_for_workbook(workbook_id.as_str())?;
118 let canonical = located.workbook_id.clone();
119 self.register_location(&located);
120 Ok(canonical)
121 }
122
123 fn scan_for_workbook(&self, candidate: &str) -> Result<LocatedWorkbook> {
124 let candidate_lower = candidate.to_ascii_lowercase();
125
126 if let Some(single) = self.config.single_workbook() {
127 let metadata = fs::metadata(single)?;
128 let canonical = WorkbookId(hash_path_metadata(single, &metadata));
129 let slug = single
130 .file_stem()
131 .map(|s| s.to_string_lossy().to_string())
132 .unwrap_or_else(|| "workbook".to_string());
133 let short_id = make_short_workbook_id(&slug, canonical.as_str());
134 if candidate == canonical.as_str() || candidate_lower == short_id {
135 return Ok(LocatedWorkbook {
136 workbook_id: canonical,
137 short_id,
138 path: single.to_path_buf(),
139 });
140 }
141 return Err(anyhow!(
142 "workbook id {} not found in single-workbook mode (expected {} or {})",
143 candidate,
144 canonical.as_str(),
145 short_id
146 ));
147 }
148
149 for entry in WalkDir::new(&self.config.workspace_root) {
150 let entry = entry?;
151 if !entry.file_type().is_file() {
152 continue;
153 }
154 let path = entry.path();
155 if !has_supported_extension(&self.config.supported_extensions, path) {
156 continue;
157 }
158 let metadata = entry.metadata()?;
159 let canonical = WorkbookId(hash_path_metadata(path, &metadata));
160 let slug = path
161 .file_stem()
162 .map(|s| s.to_string_lossy().to_string())
163 .unwrap_or_else(|| "workbook".to_string());
164 let short_id = make_short_workbook_id(&slug, canonical.as_str());
165 if candidate == canonical.as_str() || candidate_lower == short_id {
166 return Ok(LocatedWorkbook {
167 workbook_id: canonical,
168 short_id,
169 path: path.to_path_buf(),
170 });
171 }
172 }
173 Err(anyhow!("workbook id {} not found", candidate))
174 }
175
176 fn register_location(&self, located: &LocatedWorkbook) {
177 self.index
178 .write()
179 .insert(located.workbook_id.clone(), located.path.clone());
180 self.alias_index.write().insert(
181 located.short_id.to_ascii_lowercase(),
182 located.workbook_id.clone(),
183 );
184 }
185}
186
187struct LocatedWorkbook {
188 workbook_id: WorkbookId,
189 short_id: String,
190 path: PathBuf,
191}
192
193fn has_supported_extension(allowed: &[String], path: &Path) -> bool {
194 path.extension()
195 .and_then(|ext| ext.to_str())
196 .map(|ext| {
197 let lower = ext.to_ascii_lowercase();
198 allowed.iter().any(|candidate| candidate == &lower)
199 })
200 .unwrap_or(false)
201}