1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, RwLock};
4
5use anyhow::Result;
6
7use petgraph_live::cache::GenerationCache;
8use petgraph_live::live::{GraphState, GraphStateConfig};
9use petgraph_live::snapshot::{Compression, SnapshotConfig, SnapshotFormat};
10
11use crate::config::{self, GlobalConfig, ResolvedConfig, WikiEntry};
12use crate::graph::{CommunityData, WikiGraph, WikiGraphCache};
13use crate::index_manager::{IndexReport, SpaceIndexManager, StalenessKind, UpdateReport};
14use crate::index_schema::IndexSchema;
15use crate::space_builder;
16use crate::type_registry::SpaceTypeRegistry;
17
18pub struct SpaceContext {
22 pub name: String,
24 pub wiki_root: PathBuf,
26 pub repo_root: PathBuf,
28 pub type_registry: Arc<SpaceTypeRegistry>,
30 pub index_schema: IndexSchema,
32 pub index_manager: Arc<SpaceIndexManager>,
34 pub graph_cache: WikiGraphCache,
36 pub community_cache: GenerationCache<CommunityData>,
38}
39
40impl SpaceContext {
41 pub fn resolved_config(&self, global: &GlobalConfig) -> ResolvedConfig {
43 let wiki_cfg = config::load_wiki(&self.repo_root).unwrap_or_default();
44 config::resolve(global, &wiki_cfg)
45 }
46}
47
48pub struct EngineState {
52 pub config: GlobalConfig,
54 pub config_path: PathBuf,
56 pub state_dir: PathBuf,
58 pub spaces: HashMap<String, Arc<SpaceContext>>,
60}
61
62impl EngineState {
63 pub fn default_wiki_name(&self) -> &str {
65 &self.config.global.default_wiki
66 }
67
68 pub fn space(&self, name: &str) -> Result<&Arc<SpaceContext>> {
70 self.spaces
71 .get(name)
72 .ok_or_else(|| anyhow::anyhow!("wiki \"{name}\" is not mounted"))
73 }
74
75 pub fn resolve_wiki_name<'a>(&'a self, explicit: Option<&'a str>) -> &'a str {
77 explicit.unwrap_or(self.default_wiki_name())
78 }
79
80 pub fn index_path_for(&self, wiki_name: &str) -> PathBuf {
82 self.state_dir.join("indexes").join(wiki_name)
83 }
84}
85
86pub struct WikiEngine {
92 pub state: Arc<RwLock<EngineState>>,
94}
95
96impl WikiEngine {
97 pub fn build(config_path: &Path) -> Result<Self> {
99 let config = config::load_global(config_path)?;
100 let state_dir = config_path.parent().unwrap_or(Path::new(".")).to_path_buf();
101
102 let mut spaces = HashMap::new();
103
104 for entry in &config.wikis {
105 match mount_space(entry, &state_dir, &config) {
106 Ok(ctx) => {
107 spaces.insert(entry.name.clone(), Arc::new(ctx));
108 }
109 Err(e) => {
110 tracing::warn!(
111 wiki = %entry.name, error = %e,
112 "failed to mount wiki, skipping",
113 );
114 }
115 }
116 }
117
118 let engine = EngineState {
119 config,
120 config_path: config_path.to_path_buf(),
121 state_dir,
122 spaces,
123 };
124
125 Ok(WikiEngine {
126 state: Arc::new(RwLock::new(engine)),
127 })
128 }
129
130 pub fn refresh_index(&self, wiki_name: &str) -> Result<UpdateReport> {
132 let engine = self
133 .state
134 .read()
135 .map_err(|_| anyhow::anyhow!("lock poisoned"))?;
136 let space = engine.space(wiki_name)?;
137 let last_commit = space.index_manager.last_commit();
138 let report = space.index_manager.update(
139 &space.wiki_root,
140 &space.repo_root,
141 last_commit.as_deref(),
142 &space.index_schema,
143 &space.type_registry,
144 )?;
145 if report.updated > 0 || report.deleted > 0 {
146 tracing::info!(
147 wiki = %wiki_name,
148 updated = report.updated,
149 deleted = report.deleted,
150 "index updated",
151 );
152 }
153 Ok(report)
154 }
155
156 pub fn rebuild_index(&self, wiki_name: &str) -> Result<IndexReport> {
158 let engine = self
159 .state
160 .read()
161 .map_err(|_| anyhow::anyhow!("lock poisoned"))?;
162 let space = engine.space(wiki_name)?;
163 let report = space.index_manager.rebuild(
164 &space.wiki_root,
165 &space.repo_root,
166 &space.index_schema,
167 &space.type_registry,
168 )?;
169 tracing::info!(
170 wiki = %wiki_name,
171 pages = report.pages_indexed,
172 duration_ms = report.duration_ms,
173 "index rebuilt",
174 );
175 Ok(report)
176 }
177
178 pub fn schema_rebuild(&self, wiki_name: &str) -> Result<()> {
181 let engine = self
182 .state
183 .read()
184 .map_err(|_| anyhow::anyhow!("lock poisoned"))?;
185 let space = engine.space(wiki_name)?;
186 match space.index_manager.staleness_kind(&space.repo_root) {
187 Ok(StalenessKind::Current) => {}
188 Ok(StalenessKind::CommitChanged) => {
189 let last = space.index_manager.last_commit();
190 space.index_manager.update(
191 &space.wiki_root,
192 &space.repo_root,
193 last.as_deref(),
194 &space.index_schema,
195 &space.type_registry,
196 )?;
197 }
198 Ok(StalenessKind::TypesChanged(types)) => {
199 tracing::info!(wiki = %wiki_name, types = ?types, "partial rebuild");
200 if let Err(e) = space.index_manager.rebuild_types(
201 &types,
202 &space.wiki_root,
203 &space.repo_root,
204 &space.index_schema,
205 &space.type_registry,
206 ) {
207 tracing::warn!(wiki = %wiki_name, error = %e, "partial rebuild failed, doing full");
208 space.index_manager.rebuild(
209 &space.wiki_root,
210 &space.repo_root,
211 &space.index_schema,
212 &space.type_registry,
213 )?;
214 }
215 }
216 Ok(StalenessKind::FullRebuildNeeded) | Err(_) => {
217 space.index_manager.rebuild(
218 &space.wiki_root,
219 &space.repo_root,
220 &space.index_schema,
221 &space.type_registry,
222 )?;
223 }
224 }
225 Ok(())
226 }
227
228 pub fn mount_wiki(&self, entry: &WikiEntry) -> Result<()> {
231 let mut engine = self
232 .state
233 .write()
234 .map_err(|_| anyhow::anyhow!("lock poisoned"))?;
235 let ctx = mount_space(entry, &engine.state_dir, &engine.config)?;
236 tracing::info!(wiki = %entry.name, "reload: mounted");
237 engine.spaces.insert(entry.name.clone(), Arc::new(ctx));
238 Ok(())
239 }
240
241 pub fn unmount_wiki(&self, name: &str) -> Result<()> {
245 let mut engine = self
246 .state
247 .write()
248 .map_err(|_| anyhow::anyhow!("lock poisoned"))?;
249 if engine.default_wiki_name() == name {
250 anyhow::bail!("\"{name}\" is the default wiki \u{2014} set a new default first");
251 }
252 if engine.spaces.remove(name).is_none() {
253 anyhow::bail!("wiki \"{name}\" is not mounted");
254 }
255 tracing::info!(wiki = %name, "reload: unmounted");
256 Ok(())
257 }
258
259 pub fn set_default(&self, name: &str) -> Result<()> {
261 let mut engine = self
262 .state
263 .write()
264 .map_err(|_| anyhow::anyhow!("lock poisoned"))?;
265 if !engine.spaces.contains_key(name) {
266 anyhow::bail!("wiki \"{name}\" is not mounted");
267 }
268 engine.config.global.default_wiki = name.to_string();
269 tracing::info!(wiki = %name, "reload: default updated");
270 Ok(())
271 }
272}
273
274fn mount_space(entry: &WikiEntry, state_dir: &Path, config: &GlobalConfig) -> Result<SpaceContext> {
277 let repo_root = PathBuf::from(&entry.path);
278 let wiki_cfg = config::load_wiki(&repo_root).unwrap_or_default();
279 let wiki_root = repo_root.join(&wiki_cfg.wiki_root);
280 let index_path = state_dir.join("indexes").join(&entry.name);
281
282 let (type_registry, index_schema) =
283 space_builder::build_space(&repo_root, &config.index.tokenizer).unwrap_or_else(|e| {
284 tracing::warn!(
285 wiki = %entry.name, error = %e,
286 "failed to build type registry, using embedded defaults"
287 );
288 space_builder::build_space_from_embedded(&config.index.tokenizer)
289 });
290
291 let index_manager = Arc::new(SpaceIndexManager::new(&entry.name, &index_path));
292
293 let search_dir = index_path.join("search-index");
294 std::fs::create_dir_all(&search_dir)?;
295
296 let status = index_manager.status(&repo_root);
298 let needs_first_build = status.as_ref().map(|s| s.built.is_none()).unwrap_or(true);
299
300 if needs_first_build {
301 tracing::info!(wiki = %entry.name, "building index for the first time");
302 if let Err(e) = index_manager.rebuild(&wiki_root, &repo_root, &index_schema, &type_registry)
303 {
304 tracing::warn!(wiki = %entry.name, error = %e, "initial index build failed");
305 }
306 } else if config.index.auto_rebuild {
307 match index_manager.staleness_kind(&repo_root) {
308 Ok(StalenessKind::Current) => {}
309 Ok(StalenessKind::CommitChanged) => {
310 tracing::info!(wiki = %entry.name, "index behind HEAD, updating");
311 let last = index_manager.last_commit();
312 if let Err(e) = index_manager.update(
313 &wiki_root,
314 &repo_root,
315 last.as_deref(),
316 &index_schema,
317 &type_registry,
318 ) {
319 tracing::warn!(wiki = %entry.name, error = %e, "incremental update failed");
320 }
321 }
322 Ok(StalenessKind::TypesChanged(types)) => {
323 tracing::info!(wiki = %entry.name, types = ?types, "types changed, partial rebuild");
324 if let Err(e) = index_manager.rebuild_types(
325 &types,
326 &wiki_root,
327 &repo_root,
328 &index_schema,
329 &type_registry,
330 ) {
331 tracing::warn!(wiki = %entry.name, error = %e, "partial rebuild failed, doing full");
332 let _ = index_manager.rebuild(
333 &wiki_root,
334 &repo_root,
335 &index_schema,
336 &type_registry,
337 );
338 }
339 }
340 Ok(StalenessKind::FullRebuildNeeded) => {
341 tracing::info!(wiki = %entry.name, "index stale, rebuilding");
342 if let Err(e) =
343 index_manager.rebuild(&wiki_root, &repo_root, &index_schema, &type_registry)
344 {
345 tracing::warn!(wiki = %entry.name, error = %e, "index rebuild failed");
346 }
347 }
348 Err(e) => {
349 tracing::warn!(wiki = %entry.name, error = %e, "staleness check failed, rebuilding");
350 let _ =
351 index_manager.rebuild(&wiki_root, &repo_root, &index_schema, &type_registry);
352 }
353 }
354 } else if let Ok(ref s) = status
355 && s.stale
356 {
357 tracing::warn!(
358 wiki = %entry.name,
359 "index stale — run `llm-wiki index rebuild --wiki {}`",
360 entry.name,
361 );
362 }
363
364 if let Err(e) = index_manager.open(
366 &index_schema,
367 Some((&wiki_root, &repo_root, &type_registry)),
368 ) {
369 tracing::warn!(wiki = %entry.name, error = %e, "failed to open index");
370 }
371
372 let resolved_cfg = config::resolve(config, &wiki_cfg);
373 let type_registry = Arc::new(type_registry);
374 let graph_cache = {
375 let im_key = index_manager.clone();
376 let im_build = index_manager.clone();
377 let is = index_schema.clone();
378 let tr = Arc::clone(&type_registry);
379 build_wiki_graph_cache(
380 &entry.name,
381 state_dir,
382 &resolved_cfg.graph,
383 move || Ok(im_key.generation().to_string()),
384 move || {
385 let searcher = im_build.searcher().map_err(|e| {
386 petgraph_live::snapshot::SnapshotError::Io(std::io::Error::other(e.to_string()))
387 })?;
388 crate::graph::build_graph(
389 &searcher,
390 &is,
391 &crate::graph::GraphFilter::default(),
392 &tr,
393 )
394 .map_err(|e| {
395 petgraph_live::snapshot::SnapshotError::Io(std::io::Error::other(e.to_string()))
396 })
397 },
398 )?
399 };
400
401 Ok(SpaceContext {
402 name: entry.name.clone(),
403 wiki_root,
404 repo_root,
405 type_registry,
406 index_schema,
407 index_manager,
408 graph_cache,
409 community_cache: GenerationCache::new(),
410 })
411}
412
413fn build_wiki_graph_cache(
414 wiki_name: &str,
415 state_dir: &Path,
416 graph_cfg: &crate::config::GraphConfig,
417 key_fn: impl Fn() -> Result<String, petgraph_live::snapshot::SnapshotError> + Send + Sync + 'static,
418 build_fn: impl Fn() -> Result<WikiGraph, petgraph_live::snapshot::SnapshotError>
419 + Send
420 + Sync
421 + 'static,
422) -> Result<WikiGraphCache> {
423 if !graph_cfg.snapshot {
424 return Ok(WikiGraphCache::NoSnapshot(GenerationCache::new()));
425 }
426
427 let compression = match graph_cfg.snapshot_format.as_str() {
428 "bincode+lz4" => Compression::Lz4,
429 "bincode+zstd" => Compression::Zstd { level: 3 },
430 _ => Compression::None,
431 };
432
433 let snap_cfg = SnapshotConfig {
434 dir: state_dir.join("snapshots").join(wiki_name),
435 name: "wiki-graph".into(),
436 key: None,
437 format: SnapshotFormat::Bincode,
438 compression,
439 keep: graph_cfg.snapshot_keep as usize,
440 };
441
442 let state = GraphState::builder(GraphStateConfig::new(snap_cfg))
443 .key_fn(key_fn)
444 .build_fn(build_fn)
445 .init()
446 .map_err(|e| anyhow::anyhow!("graph snapshot init failed: {e}"))?;
447
448 Ok(WikiGraphCache::WithSnapshot(state))
449}