Skip to main content

lore_engine/engine/
vault_ops.rs

1//! Core vault operations — pure business logic.
2//!
3//! No framework dependencies (no Tauri, no Qt).
4//! Takes &`AppState`, returns Result<T, `LoreError`>.
5
6use super::error::LoreError;
7use super::{db, file_loader, graph::WikiGraph, link_parser, search};
8use crate::state::AppState;
9use crate::types::{VaultInfo, PageData, BacklinkEntry, PageListEntry};
10use rusqlite::Connection;
11use std::collections::HashSet;
12use std::path::Path;
13
14/// Resolve a wiki link slug using shortest-path matching.
15///
16/// If the exact slug exists, returns it unchanged. Otherwise, searches all known
17/// slugs for one that ends with `/<slug>` (folder-relative match). If exactly one
18/// match is found, returns that full slug. If ambiguous or no match, returns the
19/// original slug (which will create a placeholder).
20fn resolve_link_slug(short_slug: &str, known_slugs: &HashSet<String>) -> String {
21    // Exact match — fast path
22    if known_slugs.contains(short_slug) {
23        return short_slug.to_string();
24    }
25
26    // Suffix match: find slugs ending with "/short_slug"
27    let suffix = format!("/{short_slug}");
28    let matches: Vec<&String> = known_slugs
29        .iter()
30        .filter(|s| s.ends_with(&suffix))
31        .collect();
32
33    if matches.len() == 1 {
34        return matches[0].clone();
35    }
36
37    // Ambiguous or no match — return original
38    short_slug.to_string()
39}
40
41/// Collect all known slugs from the DB for fuzzy link resolution.
42fn get_known_slugs(conn: &Connection) -> Result<HashSet<String>, LoreError> {
43    db::get_all_slugs(conn)
44}
45
46/// Public accessor for known slugs — used by the file watcher.
47pub(crate) fn get_known_slugs_from(conn: &Connection) -> Result<HashSet<String>, LoreError> {
48    get_known_slugs(conn)
49}
50
51/// Validate that a path is relative and doesn't escape the vault directory.
52///
53/// Rejects absolute paths, `..` components, and paths starting with `/` or `\`.
54fn validate_relative_path(path: &str) -> Result<(), LoreError> {
55    let p = Path::new(path);
56    if p.is_absolute() {
57        return Err(LoreError::UnsafePath(path.to_string()));
58    }
59    for component in p.components() {
60        if matches!(component, std::path::Component::ParentDir) {
61            return Err(LoreError::UnsafePath(path.to_string()));
62        }
63    }
64    Ok(())
65}
66
67/// Extract wiki links from content, resolve slugs, create placeholder pages for
68/// unknown targets, insert link rows, and update FTS. Returns the resolved target
69/// slugs (for graph updates).
70///
71/// This is the shared link-sync logic used by `open_vault`, `save_page`, and the
72/// file watcher.
73pub(crate) fn sync_page_links(
74    conn: &Connection,
75    source_slug: &str,
76    title: &str,
77    content: &str,
78    known_slugs: &HashSet<String>,
79) -> Result<Vec<String>, LoreError> {
80    let wiki_links = link_parser::extract_links(content);
81
82    // Resolve each link slug once upfront
83    let resolved: Vec<(String, &link_parser::WikiLink)> = wiki_links
84        .iter()
85        .map(|wl| (resolve_link_slug(&wl.slug, known_slugs), wl))
86        .collect();
87
88    db::delete_links_from(conn, source_slug)?;
89
90    let link_rows: Vec<db::LinkRow> = resolved
91        .iter()
92        .map(|(slug, wl)| db::LinkRow {
93            source_slug: source_slug.to_string(),
94            target_slug: slug.clone(),
95            alias: wl.alias.clone(),
96            line_number: Some(i32::try_from(wl.line_number).unwrap_or(i32::MAX)),
97        })
98        .collect();
99
100    // Create placeholder pages for unknown link targets
101    for (slug, wl) in &resolved {
102        if db::get_page(conn, slug)?.is_none() {
103            db::upsert_page(conn, slug, &wl.target, None, None, true)?;
104        }
105    }
106
107    db::insert_links(conn, &link_rows)?;
108    db::update_fts(conn, source_slug, title, content)?;
109
110    let target_slugs: Vec<String> = resolved.into_iter().map(|(slug, _)| slug).collect();
111    Ok(target_slugs)
112}
113
114/// Open a folder as a Lore vault. Creates .lore/ directory, scans all .md files,
115/// populates DB and graph. Returns vault info on success.
116pub fn open_vault(folder_path: &Path, state: &AppState) -> Result<VaultInfo, LoreError> {
117    let conn = db::open_db(folder_path)?;
118
119    // Scan files
120    let scanned = file_loader::scan_folder(folder_path)?;
121
122    // Incremental update
123    let existing_hashes = db::get_all_hashes(&conn)?;
124    let (changed, deleted) = file_loader::diff_scan(&scanned, &existing_hashes);
125
126    conn.execute_batch("BEGIN TRANSACTION")?;
127
128    for slug in &deleted {
129        db::delete_page(&conn, slug)?;
130        log::info!("Deleted page: {slug}");
131    }
132
133    // Pass 1: upsert all changed pages so slugs are known for link resolution
134    for file in &changed {
135        db::upsert_page(
136            &conn,
137            &file.slug,
138            &file.title,
139            Some(&file.relative_path),
140            Some(&file.content_hash),
141            false,
142        )?;
143    }
144
145    // Collect all known slugs for fuzzy link resolution
146    let known_slugs = get_known_slugs(&conn)?;
147
148    // Pass 2: extract and resolve links
149    for file in &changed {
150        sync_page_links(&conn, &file.slug, &file.title, &file.content, &known_slugs)?;
151    }
152
153    conn.execute_batch("COMMIT")?;
154
155    // Build in-memory graph
156    let all_pages = db::get_all_pages(&conn)?;
157    let mut graph = WikiGraph::new();
158
159    for page in &all_pages {
160        graph.upsert_node(&page.slug, &page.title, page.is_placeholder);
161    }
162
163    for page in &all_pages {
164        let targets = db::get_outgoing_targets(&conn, &page.slug)?;
165        if !targets.is_empty() {
166            graph.set_outgoing_edges(&page.slug, &targets);
167        }
168    }
169
170    let orphans = graph.cleanup_orphaned_placeholders();
171    for slug in &orphans {
172        if let Err(e) = db::delete_page(&conn, slug) {
173            log::warn!("Failed to clean up orphan placeholder '{slug}': {e}");
174        }
175    }
176
177    let (node_count, edge_count) = graph.stats();
178    let vault_path_str = folder_path.to_string_lossy().into_owned();
179
180    log::info!("Vault opened: {vault_path_str} ({node_count} pages, {edge_count} links)");
181
182    // Store state
183    *state.vault_path.lock()? = Some(folder_path.to_path_buf());
184    *state.db.lock()? = Some(conn);
185    *state.graph.lock()? = graph;
186
187    Ok(VaultInfo {
188        path: vault_path_str,
189        page_count: node_count,
190        link_count: edge_count,
191    })
192}
193
194/// Load a page by slug. Returns content + backlinks.
195pub fn load_page(slug: &str, state: &AppState) -> Result<PageData, LoreError> {
196    let db_lock = state.db.lock()?;
197    let conn = db_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
198    let vault_lock = state.vault_path.lock()?;
199    let vault_path = vault_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
200
201    // Try exact slug first, then fuzzy resolution
202    let page = if let Some(p) = db::get_page(conn, slug)? { p } else {
203        let known_slugs = get_known_slugs(conn)?;
204        let resolved = resolve_link_slug(slug, &known_slugs);
205        db::get_page(conn, &resolved)?
206            .ok_or_else(|| LoreError::PageNotFound(slug.to_string()))?
207    };
208
209    let content = if let Some(ref fp) = page.file_path {
210        let full_path = vault_path.join(fp);
211        std::fs::read_to_string(&full_path)
212            .map(|c| c.replace("\r\n", "\n"))?
213    } else {
214        String::new()
215    };
216
217    let backlink_pages = db::get_backlinks(conn, &page.slug)?;
218    let backlinks = backlink_pages
219        .into_iter()
220        .map(|p| BacklinkEntry {
221            slug: p.slug,
222            title: p.title,
223        })
224        .collect();
225
226    Ok(PageData {
227        slug: page.slug,
228        title: page.title,
229        content,
230        backlinks,
231        is_placeholder: page.is_placeholder,
232    })
233}
234
235/// Save page content. Re-parses links, updates DB, graph, and FTS.
236///
237/// Upserts: if the page doesn't exist, creates it automatically.
238/// If the page is a placeholder, promotes it to a real page.
239pub fn save_page(slug: &str, content: &str, state: &AppState) -> Result<(), LoreError> {
240    let db_lock = state.db.lock()?;
241    let conn = db_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
242    let vault_lock = state.vault_path.lock()?;
243    let vault_path = vault_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
244
245    let page = db::get_page(conn, slug)?;
246
247    // Derive effective title: H1 from content > existing DB title > title from slug
248    let effective_title = link_parser::extract_h1(content)
249        .unwrap_or_else(|| {
250            page.as_ref().map_or_else(
251                || link_parser::title_from_slug(slug),
252                |p| p.title.clone(),
253            )
254        });
255
256    // Determine file path and write to disk (before transaction)
257    let file_path = match &page {
258        Some(p) if !p.is_placeholder => {
259            let fp = p.file_path
260                .as_ref()
261                .ok_or_else(|| LoreError::PlaceholderPage(slug.to_string()))?
262                .clone();
263            let full_path = vault_path.join(&fp);
264            std::fs::write(&full_path, content)?;
265            fp
266        }
267        _ => {
268            // Page doesn't exist or is a placeholder — create the file.
269            let file_name = format!("{}.md", file_loader::sanitize_filename(&effective_title));
270            let full_path = vault_path.join(&file_name);
271            std::fs::write(&full_path, content)?;
272            file_name
273        }
274    };
275
276    // All DB mutations in one transaction
277    conn.execute_batch("BEGIN TRANSACTION")?;
278
279    let tx_result = (|| -> Result<Vec<String>, LoreError> {
280        let hash = file_loader::content_hash(content);
281        db::upsert_page(conn, slug, &effective_title, Some(&file_path), Some(&hash), false)?;
282        db::update_fts(conn, slug, &effective_title, content)?;
283
284        let known_slugs = get_known_slugs(conn)?;
285        let target_slugs = sync_page_links(conn, slug, &effective_title, content, &known_slugs)?;
286
287        Ok(target_slugs)
288    })();
289
290    match tx_result {
291        Ok(target_slugs) => {
292            conn.execute_batch("COMMIT")?;
293
294            // Update graph (in-memory, outside transaction)
295            let mut graph = state.graph.lock()?;
296            graph.upsert_node(slug, &effective_title, false);
297
298            for resolved_slug in &target_slugs {
299                if graph.degree(resolved_slug) == 0 {
300                    if let Some(p) = db::get_page(conn, resolved_slug)? {
301                        graph.upsert_node(resolved_slug, &p.title, p.is_placeholder);
302                    }
303                }
304            }
305
306            graph.set_outgoing_edges(slug, &target_slugs);
307
308            let orphans = graph.cleanup_orphaned_placeholders();
309            drop(graph);
310            for orphan_slug in &orphans {
311                if let Err(e) = db::delete_page(conn, orphan_slug) {
312                    log::warn!("Failed to clean up orphan placeholder '{orphan_slug}': {e}");
313                }
314            }
315        }
316        Err(e) => {
317            let _ = conn.execute_batch("ROLLBACK");
318            return Err(e);
319        }
320    }
321
322    log::info!("Saved page: {slug}");
323    Ok(())
324}
325
326/// Create a new page. Creates the .md file on disk.
327///
328/// When `folder` is non-empty, creates the file inside that subfolder.
329/// The folder must be a relative path within the vault — absolute paths
330/// and `..` components are rejected.
331pub fn create_page(title: &str, folder: &str, state: &AppState) -> Result<PageData, LoreError> {
332    let db_lock = state.db.lock()?;
333    let conn = db_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
334    let vault_lock = state.vault_path.lock()?;
335    let vault_path = vault_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
336
337    let title_slug = link_parser::slugify(title);
338    if title_slug.is_empty() {
339        return Err(LoreError::InvalidSlug(title.to_string()));
340    }
341
342    // Validate folder to prevent path traversal
343    if !folder.is_empty() {
344        validate_relative_path(folder)?;
345    }
346
347    let safe_name = file_loader::sanitize_filename(title);
348    let (slug, file_name, full_path) = if folder.is_empty() {
349        let slug = title_slug;
350        let file_name = format!("{safe_name}.md");
351        let full_path = vault_path.join(&file_name);
352        (slug, file_name, full_path)
353    } else {
354        let slug = format!("{folder}/{title_slug}");
355        let file_name = format!("{folder}/{safe_name}.md");
356        let dir = vault_path.join(folder);
357        std::fs::create_dir_all(&dir)?;
358        let full_path = dir.join(format!("{safe_name}.md"));
359        (slug, file_name, full_path)
360    };
361
362    let initial_content = format!("# {title}\n\n");
363    std::fs::write(&full_path, &initial_content)?;
364
365    let hash = file_loader::content_hash(&initial_content);
366    db::upsert_page(conn, &slug, title, Some(&file_name), Some(&hash), false)?;
367    db::update_fts(conn, &slug, title, &initial_content)?;
368
369    let mut graph = state.graph.lock()?;
370    graph.upsert_node(&slug, title, false);
371
372    log::info!("Created page: {slug}");
373
374    Ok(PageData {
375        slug,
376        title: title.to_string(),
377        content: initial_content,
378        backlinks: Vec::new(),
379        is_placeholder: false,
380    })
381}
382
383/// Get backlinks for a page.
384pub fn get_backlinks_list(slug: &str, state: &AppState) -> Result<Vec<BacklinkEntry>, LoreError> {
385    let db_lock = state.db.lock()?;
386    let conn = db_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
387
388    let pages = db::get_backlinks(conn, slug)?;
389    Ok(pages
390        .into_iter()
391        .map(|p| BacklinkEntry {
392            slug: p.slug,
393            title: p.title,
394        })
395        .collect())
396}
397
398/// Full-text search across all pages.
399pub fn search_vault(query: &str, limit: usize, state: &AppState) -> Result<Vec<search::SearchResult>, LoreError> {
400    let db_lock = state.db.lock()?;
401    let conn = db_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
402    search::search_pages(conn, query, limit)
403}
404
405/// Quick title search for the quick switcher.
406pub fn search_titles(query: &str, state: &AppState) -> Result<Vec<search::SearchResult>, LoreError> {
407    let db_lock = state.db.lock()?;
408    let conn = db_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
409    search::search_titles(conn, query, 20)
410}
411
412/// Get all pages for the sidebar page tree.
413pub fn get_page_list(state: &AppState) -> Result<Vec<PageListEntry>, LoreError> {
414    let db_lock = state.db.lock()?;
415    let conn = db_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
416
417    let pages = db::get_all_pages(conn)?;
418    Ok(pages
419        .into_iter()
420        .map(|p| PageListEntry {
421            slug: p.slug,
422            title: p.title,
423            is_placeholder: p.is_placeholder,
424            file_path: p.file_path,
425        })
426        .collect())
427}
428
429/// Find pages that mention this page's title but don't link to it.
430pub fn find_unlinked_mentions(slug: &str, state: &AppState) -> Result<Vec<BacklinkEntry>, LoreError> {
431    let db_lock = state.db.lock()?;
432    let conn = db_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
433
434    let page = db::get_page(conn, slug)?
435        .ok_or_else(|| LoreError::PageNotFound(slug.to_string()))?;
436
437    // FTS search for the title
438    let fts_results = search::search_pages(conn, &page.title, 100)?;
439
440    // Get pages that already link to this slug
441    let backlinks = db::get_backlinks(conn, slug)?;
442    let linked_slugs: HashSet<String> = backlinks.into_iter().map(|p| p.slug).collect();
443
444    let mentions: Vec<BacklinkEntry> = fts_results
445        .into_iter()
446        .filter(|r| r.slug != slug && !linked_slugs.contains(&r.slug))
447        .map(|r| BacklinkEntry {
448            slug: r.slug,
449            title: r.title,
450        })
451        .collect();
452
453    Ok(mentions)
454}
455
456/// Get graph data for visualization.
457pub fn get_graph_data(
458    state: &AppState,
459) -> Result<(Vec<super::graph::GraphNode>, Vec<super::graph::GraphEdge>), LoreError> {
460    let graph = state.graph.lock()?;
461    Ok(graph.get_full_graph())
462}
463
464/// Rename a page. Updates slug, title, file on disk, all incoming links in DB.
465pub fn rename_page(old_slug: &str, new_title: &str, state: &AppState) -> Result<(), LoreError> {
466    let db_lock = state.db.lock()?;
467    let conn = db_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
468    let vault_lock = state.vault_path.lock()?;
469    let vault_path = vault_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
470
471    let page = db::get_page(conn, old_slug)?
472        .ok_or_else(|| LoreError::PageNotFound(old_slug.to_string()))?;
473
474    let title_slug = link_parser::slugify(new_title);
475    if title_slug.is_empty() {
476        return Err(LoreError::InvalidSlug(new_title.to_string()));
477    }
478    // Preserve folder prefix from the old slug
479    let new_slug = if let Some(slash_pos) = old_slug.rfind('/') {
480        format!("{}/{}", &old_slug[..slash_pos], title_slug)
481    } else {
482        title_slug
483    };
484
485    let backlink_pages = db::get_backlinks(conn, old_slug)?;
486
487    let new_file_path = if let Some(ref old_fp) = page.file_path {
488        let old_full = vault_path.join(old_fp);
489        let new_file_name = format!("{}.md", file_loader::sanitize_filename(new_title));
490        let new_full = old_full
491            .parent()
492            .unwrap_or(vault_path)
493            .join(&new_file_name);
494
495        std::fs::rename(&old_full, &new_full)?;
496
497        let relative = new_full
498            .strip_prefix(vault_path)
499            .unwrap_or(&new_full)
500            .to_string_lossy()
501            .replace('\\', "/");
502        Some(relative)
503    } else {
504        None
505    };
506
507    db::rename_page(conn, old_slug, &new_slug, new_title, new_file_path.as_deref())?;
508
509    db::delete_fts(conn, old_slug)?;
510    if let Some(ref fp) = new_file_path {
511        let full_path = vault_path.join(fp);
512        match std::fs::read_to_string(&full_path) {
513            Ok(content) => db::update_fts(conn, &new_slug, new_title, &content)?,
514            Err(e) => log::warn!("Could not read renamed file for FTS update: {e}"),
515        }
516    }
517
518    let mut graph = state.graph.lock()?;
519    let forward = graph.get_forward_links(old_slug);
520    graph.remove_node(old_slug);
521    graph.upsert_node(&new_slug, new_title, false);
522    graph.set_outgoing_edges(&new_slug, &forward);
523
524    // Rewrite [[wikilinks]] in all files that linked to the old slug,
525    // then resync their link rows and FTS entries.
526    let known_slugs = get_known_slugs(conn)?;
527    for bp in &backlink_pages {
528        if let Some(ref fp) = bp.file_path {
529            let full_path = vault_path.join(fp);
530            if let Ok(content) = std::fs::read_to_string(&full_path) {
531                let updated = link_parser::replace_wikilinks(&content, old_slug, new_title);
532                if updated != content {
533                    if let Err(e) = std::fs::write(&full_path, &updated) {
534                        log::error!("Failed to rewrite links in {fp}: {e}");
535                        continue;
536                    }
537                    // Resync this backlink page's links and FTS
538                    sync_page_links(conn, &bp.slug, &bp.title, &updated, &known_slugs)?;
539                    db::update_fts(conn, &bp.slug, &bp.title, &updated)?;
540                    // Update graph edges for this backlink page
541                    if let Ok(targets) = db::get_outgoing_targets(conn, &bp.slug) {
542                        graph.set_outgoing_edges(&bp.slug, &targets);
543                    }
544                }
545            }
546        }
547    }
548
549    log::info!("Renamed page: {old_slug} -> {new_slug}");
550    Ok(())
551}
552
553/// Delete a page. Removes file from disk, DB entry, graph node.
554pub fn delete_page(slug: &str, state: &AppState) -> Result<(), LoreError> {
555    let db_lock = state.db.lock()?;
556    let conn = db_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
557    let vault_lock = state.vault_path.lock()?;
558    let vault_path = vault_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
559
560    let page = db::get_page(conn, slug)?
561        .ok_or_else(|| LoreError::PageNotFound(slug.to_string()))?;
562
563    if let Some(ref fp) = page.file_path {
564        let full_path = vault_path.join(fp);
565        if full_path.exists() {
566            trash::delete(&full_path)
567                .map_err(|e| LoreError::Io(std::io::Error::other(e.to_string())))?;
568        }
569    }
570
571    let backlinks = db::get_backlinks(conn, slug)?;
572
573    // DB mutations in a transaction (file already trashed above)
574    conn.execute_batch("BEGIN TRANSACTION")?;
575
576    let tx_result = if backlinks.is_empty() {
577        db::delete_page(conn, slug)
578    } else {
579        (|| -> Result<(), LoreError> {
580            db::upsert_page(conn, slug, &page.title, None, None, true)?;
581            db::delete_links_from(conn, slug)?;
582            db::delete_fts(conn, slug)?;
583            Ok(())
584        })()
585    };
586
587    match tx_result {
588        Ok(()) => {
589            conn.execute_batch("COMMIT")?;
590
591            // Update graph (in-memory, outside transaction)
592            if backlinks.is_empty() {
593                let mut graph = state.graph.lock()?;
594                graph.remove_node(slug);
595            } else {
596                let mut graph = state.graph.lock()?;
597                graph.upsert_node(slug, &page.title, true);
598                graph.set_outgoing_edges(slug, &[]);
599
600                let orphans = graph.cleanup_orphaned_placeholders();
601                drop(graph);
602                for orphan_slug in &orphans {
603                    if let Err(e) = db::delete_page(conn, orphan_slug) {
604                        log::warn!("Failed to clean up orphan placeholder '{orphan_slug}': {e}");
605                    }
606                }
607            }
608        }
609        Err(e) => {
610            let _ = conn.execute_batch("ROLLBACK");
611            return Err(e);
612        }
613    }
614
615    log::info!("Deleted page: {slug}");
616    Ok(())
617}