Skip to main content

docling_rag/
pipeline.rs

1//! End-to-end orchestration: ingestion (source → convert → chunk → embed → store)
2//! and querying (retrieve → optional LLM answer synthesis).
3
4use crate::chunk::Chunker;
5use crate::embed::{self, Embedder};
6use crate::llm::{self, ChatModel, Message};
7use crate::metrics::{self, ProcessingMetrics, Timings};
8use crate::model::{content_hash, Document, RetrievalMode, Scored};
9use crate::retrieve::Retriever;
10use crate::source::{self, DocumentSource, SourceRef};
11use crate::store::{self, VectorStore};
12use crate::{RagConfig, RagError, Result};
13use docling::{DocumentConverter, InputFormat, SourceDocument};
14use std::sync::Arc;
15
16/// A fully-wired RAG pipeline built from a [`RagConfig`].
17#[derive(Clone)]
18pub struct Pipeline {
19    cfg: RagConfig,
20    source: Arc<dyn DocumentSource>,
21    embedder: Arc<dyn Embedder>,
22    store: Arc<dyn VectorStore>,
23    chat: Option<Arc<dyn ChatModel>>,
24    chunker: Chunker,
25    /// Keyword index shared by every retriever this pipeline hands out.
26    bm25_cache: Arc<crate::retrieve::bm25::Bm25Cache>,
27}
28
29/// What happened to one document during ingestion.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum IngestOutcome {
32    /// Ingested; carries the number of chunks stored.
33    Ingested(usize),
34    /// Skipped because an identical document (same hash) was already stored.
35    Skipped,
36}
37
38/// Aggregate ingestion result over a whole source.
39#[derive(Debug, Clone, Default)]
40pub struct IngestReport {
41    pub documents_ingested: usize,
42    pub documents_skipped: usize,
43    pub documents_failed: usize,
44    pub chunks_added: usize,
45}
46
47/// A synthesized answer plus the chunks it was grounded in.
48#[derive(Debug, Clone)]
49pub struct Answer {
50    pub text: String,
51    pub sources: Vec<Scored>,
52}
53
54/// Per-ingest conversion switches — docling's optional enrichment models
55/// (each needs its model files on disk; see download_dependencies.sh).
56/// Off by default: enrichment multiplies conversion time.
57#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
58pub struct ConvertOptions {
59    /// Classify pictures (chart/logo/…) — `.models/picture_classifier.onnx`.
60    pub enrich_pictures: bool,
61    /// Transcribe code blocks with the CodeFormula VLM (`--enrich` download).
62    pub enrich_code: bool,
63    /// Transcribe formulas to LaTeX with the CodeFormula VLM.
64    pub enrich_formulas: bool,
65}
66
67impl ConvertOptions {
68    /// A converter with these enrichments enabled.
69    fn converter(self) -> DocumentConverter {
70        DocumentConverter::new()
71            .do_picture_classification(self.enrich_pictures)
72            .do_code_enrichment(self.enrich_code)
73            .do_formula_enrichment(self.enrich_formulas)
74    }
75}
76
77/// What the overlapped parse/chunk/embed stages produced for one document.
78/// Phase seconds are busy time (the stages overlap on the wall clock).
79struct StagedOutcome {
80    pages: Option<usize>,
81    parse_secs: f64,
82    chunk_secs: f64,
83    embed_secs: f64,
84    embedded_words: usize,
85    chunks: usize,
86    markdown: String,
87}
88
89impl Pipeline {
90    /// Build every component from config. The LLM client is created only if
91    /// `OPENROUTER_API_KEY` is set (LLM-backed modes error otherwise).
92    pub async fn from_config(cfg: &RagConfig) -> Result<Self> {
93        // RAG_OCR_LANG maps onto docling-pdf's own language selector
94        // (English is both defaults, so only `ch` needs forwarding) —
95        // resolved once per process at first PDF use; explicit
96        // DOCLING_RS_OCR_LANG / DOCLING_OCR_* env always wins.
97        if cfg.ocr_lang == crate::config::OcrLang::Ch
98            && docling_core::env::nonempty("DOCLING_RS_OCR_LANG").is_none()
99        {
100            std::env::set_var("DOCLING_RS_OCR_LANG", "ch");
101        }
102        let source = source::from_config(cfg)?;
103        let embedder = embed::from_config(cfg)?;
104        let store = store::from_config(cfg).await?;
105        let chat = match cfg.openrouter_api_key {
106            Some(_) => Some(llm::from_config(cfg)?),
107            None => None,
108        };
109        let chunker = Chunker::from_config(cfg);
110        Ok(Pipeline {
111            cfg: cfg.clone(),
112            source,
113            embedder,
114            store,
115            chat,
116            chunker,
117            bm25_cache: Arc::new(crate::retrieve::bm25::Bm25Cache::new()),
118        })
119    }
120
121    /// The underlying store (for counts, admin, tests).
122    pub fn store(&self) -> &Arc<dyn VectorStore> {
123        &self.store
124    }
125
126    /// The resolved configuration this pipeline was built from.
127    /// Whether answer synthesis (and the LLM-backed query rewrites) is
128    /// available — an `OPENROUTER_API_KEY` was configured. The UI reads this
129    /// from `/health` to explain a missing answer instead of offering a
130    /// checkbox that can only fail.
131    pub fn has_llm(&self) -> bool {
132        self.chat.is_some()
133    }
134
135    pub fn config(&self) -> &RagConfig {
136        &self.cfg
137    }
138
139    /// A retriever over this pipeline's store/embedder/LLM.
140    ///
141    /// Every retriever shares this pipeline's keyword index, so the BM25 corpus
142    /// is tokenized once and reused across searches (and across API requests)
143    /// until an ingest invalidates it.
144    pub fn retriever(&self) -> Retriever {
145        Retriever::new(self.store.clone(), self.embedder.clone(), self.chat.clone())
146            .with_rrf_k(self.cfg.rrf_k)
147            .with_multiquery_n(self.cfg.multiquery_n)
148            .with_bm25_params(crate::retrieve::bm25::Bm25Params {
149                k1: self.cfg.bm25_k1,
150                b: self.cfg.bm25_b,
151            })
152            .with_bm25_cache(self.bm25_cache.clone())
153    }
154
155    /// Drop the cached BM25 index — the corpus changed under it.
156    pub fn invalidate_keyword_index(&self) {
157        self.bm25_cache.invalidate();
158    }
159
160    /// Ingest a single document reference. Deduplicates on content hash, and
161    /// records per-phase processing metrics in the document's JSON metadata.
162    ///
163    /// Processing is **streaming**: `docling.rs`'s `convert_streaming` emits
164    /// Markdown as it is produced (per page for PDF), and chunking + embedding
165    /// run concurrently on the pieces — parsing of page N overlaps embedding of
166    /// pages < N. Phase timings measure busy time, so throughput metrics stay
167    /// meaningful even though the phases overlap on the wall clock.
168    pub async fn ingest_ref(&self, r: &SourceRef) -> Result<IngestOutcome> {
169        let bytes = self.source.fetch(r).await?;
170        self.ingest_bytes(r, bytes).await
171    }
172
173    /// Ingest a document from in-memory bytes — the same staged pipeline as
174    /// [`Self::ingest_ref`] minus the source fetch. Used by the REST API's
175    /// upload endpoint, where the bytes arrive in the request body; `r.uri`
176    /// still identifies the document (`upload:///<name>` by convention) for
177    /// dedup and stale-row cleanup.
178    pub async fn ingest_bytes(&self, r: &SourceRef, bytes: Vec<u8>) -> Result<IngestOutcome> {
179        self.ingest_bytes_with(r, bytes, ConvertOptions::default())
180            .await
181    }
182
183    /// [`Self::ingest_bytes`] with explicit conversion options (enrichments).
184    pub async fn ingest_bytes_with(
185        &self,
186        r: &SourceRef,
187        bytes: Vec<u8>,
188        opts: ConvertOptions,
189    ) -> Result<IngestOutcome> {
190        let hash = content_hash(&bytes);
191        if self.store.find_document_by_hash(&hash).await?.is_some() {
192            tracing::debug!(uri = %r.uri, "skipping unchanged document");
193            return Ok(IngestOutcome::Skipped);
194        }
195        let file_bytes = bytes.len() as u64;
196        tracing::info!(
197            uri = %r.uri,
198            name = %r.name,
199            bytes = file_bytes,
200            "processing document"
201        );
202
203        // Remove stale rows for this source first: leftovers from interrupted
204        // runs, or previous versions of a file whose content changed.
205        self.store.delete_documents_by_source(&r.uri).await?;
206        // Everything below rewrites the corpus, so the keyword index built from
207        // it is stale from here on — including the case where a re-ingested
208        // document happens to produce exactly as many chunks as it replaced,
209        // which the cache's count fingerprint alone would not notice.
210        self.invalidate_keyword_index();
211
212        // The document row must exist before its chunks (FK). It is inserted
213        // with a sentinel hash — the real hash is written only on success, so an
214        // interrupted run can never satisfy the dedup check above and the
215        // document is reprocessed next time. Title is refined (first heading)
216        // and metrics attached with the final upsert.
217        let mut doc = Document::new(&r.uri, stem(&r.name), format!("pending:{hash}"))
218            .with_metadata(serde_json::json!({ "source": r.uri }));
219        self.store.upsert_document(&doc).await?;
220
221        // Run the staged pipeline; on failure roll back the document row and any
222        // partially-inserted chunks so a retry reprocesses from scratch instead
223        // of being skipped by the hash dedup.
224        let staged = match self.cfg.chunker {
225            crate::config::ChunkerKind::Window => {
226                self.ingest_streaming(r, &doc.id, bytes, opts).await
227            }
228            // docling's chunkers walk the finished document tree, so conversion
229            // is whole-document — but the chunks stream into embedding as the
230            // chunkers produce them.
231            _ => self.ingest_docling(r, &doc.id, bytes, opts).await,
232        };
233        let out = match staged {
234            Ok(out) => out,
235            Err(e) => {
236                if let Err(del) = self.store.delete_document(&doc.id).await {
237                    tracing::warn!(uri = %r.uri, error = %del, "rollback of failed ingest also failed");
238                }
239                return Err(e);
240            }
241        };
242        let StagedOutcome {
243            pages,
244            parse_secs,
245            chunk_secs,
246            embed_secs,
247            embedded_words,
248            chunks: n,
249            markdown,
250        } = out;
251
252        let words = markdown.split_whitespace().count();
253        let title = first_heading(&markdown).unwrap_or_else(|| stem(&r.name));
254
255        // Optional local FS mirror of the parsed documents (RAG_DOCUMENTS_OUTPUT):
256        // same directory structure as the source, `.md` appended to every name
257        // (also for original .md inputs — conversion may reformat them).
258        // Best-effort: a failed write never fails ingest.
259        if let Some(dir) = &self.cfg.documents_output {
260            if let Err(e) = dump_markdown(dir, &r.rel_path, &markdown).await {
261                tracing::warn!(uri = %r.uri, error = %e, "failed to write markdown dump");
262            }
263        }
264
265        let m = ProcessingMetrics::compute(
266            file_bytes,
267            pages,
268            words,
269            n,
270            embedded_words,
271            Timings {
272                parse_secs,
273                chunk_secs,
274                embed_secs,
275            },
276        );
277        tracing::info!(
278            uri = %r.uri,
279            pages = ?m.pages,
280            words = m.words,
281            chunks = m.chunks,
282            parse_wps = ?m.parsing.words_per_sec,
283            embed_wps = ?m.embedding.words_per_sec,
284            "ingested document"
285        );
286        doc.title = title;
287        doc.hash = hash; // success: replace the sentinel with the real hash
288                         // The parsed Markdown rides along in the metadata so the API can serve
289                         // it back (GET /api/documents/{id}/markdown) without re-converting.
290        doc.metadata =
291            serde_json::json!({ "source": r.uri, "metrics": m.to_json(), "markdown": markdown });
292        self.store.upsert_document(&doc).await?;
293        Ok(IngestOutcome::Ingested(n))
294    }
295
296    /// The docling-chunker variant of [`Self::ingest_streaming`]
297    /// (`RAG_CHUNKER=hierarchical|hybrid`): the chunkers need the complete
298    /// document tree, so conversion runs whole-document on a blocking thread —
299    /// but the chunks *stream*: batches are handed to the embed/insert worker
300    /// as the chunkers produce them, overlapping chunking with embedding.
301    async fn ingest_docling(
302        &self,
303        r: &SourceRef,
304        doc_id: &str,
305        bytes: Vec<u8>,
306        opts: ConvertOptions,
307    ) -> Result<StagedOutcome> {
308        let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel::<Vec<crate::model::Chunk>>(4);
309        let embed_worker = self.spawn_embed_worker(chunk_rx);
310
311        let name = r.name.clone();
312        let kind = self.cfg.chunker;
313        let tokenizer = self.cfg.chunk_tokenizer.clone();
314        let max_tokens = self.cfg.chunk_size;
315        let doc_id_owned = doc_id.to_string();
316        type Converted = (Option<usize>, f64, f64, String);
317        let producer = tokio::task::spawn_blocking(move || -> Result<Converted> {
318            let ext = name.rsplit('.').next().unwrap_or("");
319            let fmt = InputFormat::from_extension(ext)
320                .ok_or_else(|| RagError::Conversion(format!("unsupported extension '.{ext}'")))?;
321            let pages = metrics::count_pages(fmt, &bytes);
322            let src = SourceDocument::from_bytes(name, fmt, bytes);
323            let t = std::time::Instant::now();
324            let result = opts
325                .converter()
326                .convert(src)
327                .map_err(|e| RagError::Conversion(e.to_string()))?;
328            let parse_secs = t.elapsed().as_secs_f64();
329            let markdown = result.document.export_to_markdown();
330
331            const BATCH: usize = 64;
332            let mut backlog: Vec<crate::model::Chunk> = Vec::with_capacity(BATCH);
333            let t = std::time::Instant::now();
334            // chunk_secs counts chunker busy time only: time blocked handing a
335            // full batch to the embed worker is subtracted (that would bill
336            // embedding slowness to chunking).
337            let mut send_secs = 0.0f64;
338            let mut disconnected = false;
339            crate::chunk::docling_chunks_with(
340                &doc_id_owned,
341                &result.document,
342                kind,
343                tokenizer.as_deref(),
344                max_tokens,
345                &mut |chunk| {
346                    backlog.push(chunk);
347                    if backlog.len() < BATCH {
348                        return true;
349                    }
350                    let ts = std::time::Instant::now();
351                    // A send failure means the embed worker died; its error wins.
352                    disconnected = chunk_tx
353                        .blocking_send(std::mem::take(&mut backlog))
354                        .is_err();
355                    send_secs += ts.elapsed().as_secs_f64();
356                    !disconnected
357                },
358            )?;
359            if !disconnected && !backlog.is_empty() {
360                let _ = chunk_tx.blocking_send(backlog);
361            }
362            let chunk_secs = (t.elapsed().as_secs_f64() - send_secs).max(0.0);
363            Ok((pages, parse_secs, chunk_secs, markdown))
364        });
365
366        // Join stages; producer errors (bad document) take precedence.
367        let (pages, parse_secs, chunk_secs, markdown) = producer
368            .await
369            .map_err(|e| RagError::Conversion(format!("convert join: {e}")))??;
370        let (embed_secs, embedded_words, n) = embed_worker
371            .await
372            .map_err(|e| RagError::Embedding(format!("embed join: {e}")))??;
373
374        Ok(StagedOutcome {
375            pages,
376            parse_secs,
377            chunk_secs,
378            embed_secs,
379            embedded_words,
380            chunks: n,
381            markdown,
382        })
383    }
384
385    /// Spawn the embed + insert worker: chunk batches from `rx` are embedded
386    /// and stored concurrently with whatever stage produces them. Resolves to
387    /// `(embed_secs, embedded_words, chunks_inserted)` once `rx` closes.
388    fn spawn_embed_worker(
389        &self,
390        mut rx: tokio::sync::mpsc::Receiver<Vec<crate::model::Chunk>>,
391    ) -> tokio::task::JoinHandle<Result<(f64, usize, usize)>> {
392        let embedder = self.embedder.clone();
393        let store = self.store.clone();
394        tokio::spawn(async move {
395            let (mut embed_secs, mut embedded_words, mut n_chunks) = (0.0f64, 0usize, 0usize);
396            while let Some(mut batch) = rx.recv().await {
397                let texts: Vec<String> = batch.iter().map(|c| c.text.clone()).collect();
398                let t = std::time::Instant::now();
399                let embeddings = embedder.embed(&texts).await?;
400                embed_secs += t.elapsed().as_secs_f64();
401                if embeddings.len() != batch.len() {
402                    return Err(RagError::Embedding("embedding count mismatch".into()));
403                }
404                for (chunk, emb) in batch.iter_mut().zip(embeddings) {
405                    chunk.embedding = Some(emb);
406                }
407                embedded_words += texts
408                    .iter()
409                    .map(|t| t.split_whitespace().count())
410                    .sum::<usize>();
411                n_chunks += batch.len();
412                store.insert_chunks(&batch).await?;
413            }
414            Ok((embed_secs, embedded_words, n_chunks))
415        })
416    }
417
418    /// The overlapped parse → chunk → embed/insert stages for one document.
419    async fn ingest_streaming(
420        &self,
421        r: &SourceRef,
422        doc_id: &str,
423        bytes: Vec<u8>,
424        opts: ConvertOptions,
425    ) -> Result<StagedOutcome> {
426        // --- Stage 1: parser thread. Streams Markdown pieces as converted.
427        // Bounded channel: a slow consumer applies backpressure to the converter.
428        let (md_tx, mut md_rx) = tokio::sync::mpsc::channel::<String>(16);
429        let name = r.name.clone();
430        let parser = tokio::task::spawn_blocking(move || -> Result<(Option<usize>, f64)> {
431            let ext = name.rsplit('.').next().unwrap_or("");
432            let fmt = InputFormat::from_extension(ext)
433                .ok_or_else(|| RagError::Conversion(format!("unsupported extension '.{ext}'")))?;
434            let pages = metrics::count_pages(fmt, &bytes);
435            let src = SourceDocument::from_bytes(name, fmt, bytes);
436            let mut stream = opts
437                .converter()
438                .convert_streaming(src)
439                .map_err(|e| RagError::Conversion(e.to_string()))?;
440            // parse_secs counts time inside the converter only, not time blocked
441            // on a full channel (that would bill consumer slowness to parsing).
442            let mut parse_secs = 0.0;
443            loop {
444                let t = std::time::Instant::now();
445                let item = stream.next();
446                parse_secs += t.elapsed().as_secs_f64();
447                match item {
448                    Some(Ok(piece)) => {
449                        if md_tx.blocking_send(piece).is_err() {
450                            break; // consumer failed; its error wins
451                        }
452                    }
453                    Some(Err(e)) => return Err(RagError::Conversion(e.to_string())),
454                    None => break,
455                }
456            }
457            Ok((pages, parse_secs))
458        });
459
460        // --- Stage 2: incremental chunking; completed chunks go to the embedder.
461        // --- Stage 3: embed + insert worker, concurrent with stages 1 and 2.
462        let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel::<Vec<crate::model::Chunk>>(4);
463        let embed_worker = self.spawn_embed_worker(chunk_rx);
464
465        let mut streaming = self.chunker.streaming(doc_id);
466        let mut markdown = String::new();
467        let mut chunk_secs = 0.0f64;
468        let mut backlog: Vec<crate::model::Chunk> = Vec::new();
469        const BATCH: usize = 64;
470        let mut consume_failed = false;
471        while let Some(piece) = md_rx.recv().await {
472            let t = std::time::Instant::now();
473            let ready = streaming.push(&piece);
474            chunk_secs += t.elapsed().as_secs_f64();
475            markdown.push_str(&piece);
476            backlog.extend(ready);
477            while backlog.len() >= BATCH {
478                let batch: Vec<_> = backlog.drain(..BATCH).collect();
479                if chunk_tx.send(batch).await.is_err() {
480                    consume_failed = true; // embed worker died; surface its error
481                    break;
482                }
483            }
484            if consume_failed {
485                break;
486            }
487        }
488        // Drain: remaining markdown lands in a final section, then flush backlog.
489        drop(md_rx);
490        if !consume_failed {
491            let t = std::time::Instant::now();
492            backlog.extend(streaming.finish());
493            chunk_secs += t.elapsed().as_secs_f64();
494            for batch in backlog.chunks(BATCH) {
495                if chunk_tx.send(batch.to_vec()).await.is_err() {
496                    break;
497                }
498            }
499        }
500        drop(chunk_tx);
501
502        // Join stages; parser errors (bad document) take precedence.
503        let (pages, parse_secs) = parser
504            .await
505            .map_err(|e| RagError::Conversion(format!("convert join: {e}")))??;
506        let (embed_secs, embedded_words, n) = embed_worker
507            .await
508            .map_err(|e| RagError::Embedding(format!("embed join: {e}")))??;
509
510        Ok(StagedOutcome {
511            pages,
512            parse_secs,
513            chunk_secs,
514            embed_secs,
515            embedded_words,
516            chunks: n,
517            markdown,
518        })
519    }
520
521    /// Ingest every document the configured source lists.
522    pub async fn ingest_all(&self) -> Result<IngestReport> {
523        let refs = self.source.list().await?;
524        let mut report = IngestReport::default();
525        for r in &refs {
526            match self.ingest_ref(r).await {
527                Ok(IngestOutcome::Ingested(n)) => {
528                    report.documents_ingested += 1;
529                    report.chunks_added += n;
530                }
531                Ok(IngestOutcome::Skipped) => report.documents_skipped += 1,
532                Err(e) => {
533                    report.documents_failed += 1;
534                    tracing::warn!(uri = %r.uri, error = %e, "failed to ingest document");
535                }
536            }
537        }
538        Ok(report)
539    }
540
541    /// Retrieve the top `k` chunks for a query under `mode`.
542    pub async fn query(&self, mode: RetrievalMode, query: &str, k: usize) -> Result<Vec<Scored>> {
543        self.retriever().retrieve(mode, query, k).await
544    }
545
546    /// Retrieve, then ask the LLM to answer grounded in the retrieved chunks.
547    pub async fn answer(&self, query: &str, mode: RetrievalMode, k: usize) -> Result<Answer> {
548        let chat = self.chat.as_ref().ok_or_else(|| {
549            RagError::Llm("answering needs an LLM; set OPENROUTER_API_KEY".into())
550        })?;
551        let hits = self.query(mode, query, k).await?;
552        let context = hits
553            .iter()
554            .enumerate()
555            .map(|(i, h)| format!("[{}] {}", i + 1, h.chunk.text))
556            .collect::<Vec<_>>()
557            .join("\n\n");
558        let system = "Answer the user's question using only the provided context passages. \
559                      Cite the passage numbers you used like [1]. If the context does not \
560                      contain the answer, say so.";
561        let user = format!("Context:\n{context}\n\nQuestion: {query}");
562        let text = chat
563            .complete(&[Message::system(system), Message::user(&user)])
564            .await?;
565        Ok(Answer {
566            text,
567            sources: hits,
568        })
569    }
570}
571
572/// Mirror a parsed document into the output folder: `<dir>/<rel_path>.md`, with
573/// the source's directory structure preserved and `.md` always appended
574/// (`report.pdf` → `report.pdf.md`, `notes.md` → `notes.md.md`).
575async fn dump_markdown(dir: &str, rel_path: &str, markdown: &str) -> Result<()> {
576    // Never let a hostile rel_path escape the output root.
577    let rel: std::path::PathBuf = std::path::Path::new(rel_path)
578        .components()
579        .filter(|c| matches!(c, std::path::Component::Normal(_)))
580        .collect();
581    let file_name = if rel.as_os_str().is_empty() {
582        std::path::PathBuf::from("document")
583    } else {
584        rel
585    };
586    let path = std::path::Path::new(dir).join(format!("{}.md", file_name.display()));
587    if let Some(parent) = path.parent() {
588        tokio::fs::create_dir_all(parent).await?;
589    }
590    tokio::fs::write(&path, markdown).await?;
591    tracing::debug!(path = %path.display(), "wrote markdown dump");
592    Ok(())
593}
594
595/// First `# `/`## ` heading text in a Markdown string.
596fn first_heading(md: &str) -> Option<String> {
597    for line in md.lines() {
598        let t = line.trim_start();
599        if let Some(rest) = t.strip_prefix('#') {
600            let heading = rest.trim_start_matches('#').trim();
601            if !heading.is_empty() {
602                return Some(heading.to_string());
603            }
604        }
605    }
606    None
607}
608
609/// File stem of a name (`report.md` → `report`).
610fn stem(name: &str) -> String {
611    let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
612    base.rsplit_once('.')
613        .map(|(s, _)| s)
614        .unwrap_or(base)
615        .to_string()
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn extracts_title_and_stem() {
624        assert_eq!(
625            first_heading("intro\n# Real Title\nbody"),
626            Some("Real Title".into())
627        );
628        assert_eq!(first_heading("no headings here"), None);
629        assert_eq!(stem("/a/b/report.md"), "report");
630        assert_eq!(stem("noext"), "noext");
631    }
632}