Skip to main content

llm_wiki/
engine.rs

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
18// ── SpaceContext ──────────────────────────────────────────────────────────────
19
20/// All runtime state for a single mounted wiki space.
21pub struct SpaceContext {
22    /// Registered name of this wiki space.
23    pub name: String,
24    /// Absolute path to the `wiki/` subdirectory containing Markdown pages.
25    pub wiki_root: PathBuf,
26    /// Absolute path to the git repository root (parent of `wiki/`).
27    pub repo_root: PathBuf,
28    /// Type registry compiled from the wiki's schema files.
29    pub type_registry: Arc<SpaceTypeRegistry>,
30    /// Tantivy index schema for this space.
31    pub index_schema: IndexSchema,
32    /// Lifecycle manager for the Tantivy search index.
33    pub index_manager: Arc<SpaceIndexManager>,
34    /// Graph cache — either in-memory only (NoSnapshot) or snapshot-backed (WithSnapshot).
35    pub graph_cache: WikiGraphCache,
36    /// Generation-keyed community cache. Shares the same generation key as graph_cache.
37    pub community_cache: GenerationCache<CommunityData>,
38}
39
40impl SpaceContext {
41    /// Load and resolve the per-wiki config merged with `global`.
42    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
48// ── EngineState ──────────────────────────────────────────────────────────────
49
50/// Shared mutable state protected by [`WikiEngine`]'s `RwLock`.
51pub struct EngineState {
52    /// Loaded global configuration.
53    pub config: GlobalConfig,
54    /// Absolute path to the global config file on disk.
55    pub config_path: PathBuf,
56    /// Directory that holds per-wiki index state (parent of the config file).
57    pub state_dir: PathBuf,
58    /// Map from wiki name to its mounted `SpaceContext`.
59    pub spaces: HashMap<String, Arc<SpaceContext>>,
60}
61
62impl EngineState {
63    /// Return the configured default wiki name.
64    pub fn default_wiki_name(&self) -> &str {
65        &self.config.global.default_wiki
66    }
67
68    /// Look up a mounted wiki space by name. Errors if not mounted.
69    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    /// Return `explicit` if given, otherwise the default wiki name.
76    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    /// Return the index directory path for a wiki by name.
81    pub fn index_path_for(&self, wiki_name: &str) -> PathBuf {
82        self.state_dir.join("indexes").join(wiki_name)
83    }
84}
85
86// ── WikiEngine ─────────────────────────────────────────────────────────────
87
88/// Central engine — owns all wiki spaces and exposes index/mount operations.
89///
90/// Cheap to clone (`Arc` inside). Safe to share across async tasks.
91pub struct WikiEngine {
92    /// Shared engine state protected by a reader-writer lock.
93    pub state: Arc<RwLock<EngineState>>,
94}
95
96impl WikiEngine {
97    /// Build a `WikiEngine` from the global config at `config_path`, mounting all registered wikis.
98    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    /// Incrementally update the index from git changes since the last indexed commit.
131    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    /// Rebuild the search index from scratch by walking the wiki tree.
157    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    /// Smart schema rebuild: checks staleness and does partial rebuild
179    /// when possible, full rebuild only when necessary.
180    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    /// Mount a wiki into the running engine. Called by space management
229    /// tools for hot reload.
230    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    /// Unmount a wiki from the running engine. Refuses if the wiki is
242    /// the current default. In-flight requests holding an `Arc<SpaceContext>`
243    /// complete normally.
244    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    /// Update the default wiki. The wiki must be mounted.
260    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
274// ── mount_wiki ────────────────────────────────────────────────────────────────
275
276fn 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    // Staleness check and rebuild
297    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    // Open the index for serving
365    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}