Skip to main content

mentedb_server/
handlers_ops.rs

1//! Operations endpoints: maintenance, enrichment, documents, backups, database management, readiness.
2//!
3//! Per database (mounted under `/v1` and `/v1/db/{db}`):
4//!
5//! - `POST /maintenance/decay`, `/maintenance/archive`, `/maintenance/consolidate`,
6//!   `/maintenance/sweep`: the rule based upkeep jobs, on demand.
7//! - `POST /enrich`: the LLM enrichment pipeline (extraction, entity linking,
8//!   communities, user model).
9//! - `POST /documents`, `DELETE /documents/{document_id}`: load a long text as
10//!   overlapping chunks, and forget it again.
11//! - `POST /backup`, `GET /backups` (admin): snapshot the database into a
12//!   single backup file and list those snapshots.
13//!
14//! Global:
15//!
16//! - `GET`/`POST /v1/databases`, `DELETE /v1/databases/{name}` (admin).
17//! - `GET /v1/ready`: readiness, separate from `/v1/health` liveness.
18//!
19//! There is deliberately no restore endpoint: replacing the files of a live,
20//! open database is unsafe. Restore is offline: stop the server, then call
21//! `mentedb::storage::backup::BackupManager::restore_backup(file, dir)` with
22//! an empty target directory (the data directory root for the default
23//! database, `<data>/dbs/<name>` for a named one) and start the server again.
24
25use std::collections::HashMap;
26use std::path::{Path as FsPath, PathBuf};
27use std::sync::{Arc, Mutex, OnceLock, Weak};
28use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
29
30use axum::extract::{FromRequestParts, Path, State};
31use axum::http::StatusCode;
32use axum::http::request::Parts;
33use axum::response::{IntoResponse, Response};
34use axum::routing::{delete, get, post};
35use axum::{Extension, Json, Router};
36use mentedb::consolidation::archival::ArchivalDecision;
37use mentedb::storage::backup::BackupManager;
38use mentedb_core::MemoryNode;
39use mentedb_core::memory::{AttributeValue, MemoryType};
40use mentedb_core::types::{AgentId, MemoryId, SpaceId, UserId};
41use mentedb_embedding::provider::EmbeddingProvider;
42use serde_json::{Value, json};
43use uuid::Uuid;
44
45use crate::auth::AuthenticatedAgent;
46use crate::databases::{DEFAULT_DB, Db, validate_name};
47use crate::error::ApiError;
48use crate::state::AppState;
49
50// ---------------------------------------------------------------------------
51// Configuration
52// ---------------------------------------------------------------------------
53
54/// How documents are split into chunks.
55#[derive(Clone, Debug)]
56pub struct DocumentConfig {
57    /// Target chunk length in characters.
58    pub chunk_chars: usize,
59    /// Characters carried from the end of one chunk to the start of the next.
60    pub overlap_chars: usize,
61    /// Smallest `chunk_chars` a request may ask for.
62    pub min_chunk_chars: usize,
63    /// Largest `chunk_chars` a request may ask for.
64    pub max_chunk_chars: usize,
65    /// Largest overlap as a fraction of the chunk length.
66    pub max_overlap_ratio: f32,
67    /// Most chunks one document may produce.
68    pub max_chunks: usize,
69    /// Characters that end a sentence when a paragraph must be split.
70    pub sentence_terminators: Vec<char>,
71}
72
73impl Default for DocumentConfig {
74    fn default() -> Self {
75        Self {
76            chunk_chars: 1500,
77            overlap_chars: 200,
78            min_chunk_chars: 100,
79            max_chunk_chars: 8000,
80            max_overlap_ratio: 0.5,
81            max_chunks: 10_000,
82            sentence_terminators: vec!['.', '!', '?', '\u{3002}', '\u{ff01}', '\u{ff1f}'],
83        }
84    }
85}
86
87/// How `/v1/ready` probes its dependencies.
88#[derive(Clone, Debug)]
89pub struct ReadinessConfig {
90    /// How long a successful or failed embed check is reused before the
91    /// embedder is asked again, so frequent probes stay cheap.
92    pub embed_check_interval: Duration,
93    /// Text embedded by the probe.
94    pub probe_text: String,
95    /// Longest any single check may take before it counts as failed.
96    pub check_timeout: Duration,
97}
98
99impl Default for ReadinessConfig {
100    fn default() -> Self {
101        Self {
102            embed_check_interval: Duration::from_secs(30),
103            probe_text: "readiness probe".to_string(),
104            check_timeout: Duration::from_secs(5),
105        }
106    }
107}
108
109/// Settings for the operations endpoints.
110#[derive(Clone, Debug)]
111pub struct OpsConfig {
112    /// Where backups are written (`MENTEDB_BACKUP_DIR`). `None` means
113    /// `<data_dir>/backups`. Each database gets its own subdirectory.
114    pub backup_dir: Option<PathBuf>,
115    /// Smallest cluster of near duplicates worth consolidating.
116    pub consolidation_min_cluster_size: usize,
117    /// Cosine similarity at which memories are grouped for consolidation.
118    pub consolidation_similarity: f32,
119    /// Memory ids handed to one paraphrase dedup pass, so a large database
120    /// is swept in bounded steps.
121    pub dedup_batch_size: usize,
122    /// Most memories one archive run may forget.
123    pub max_forgets_per_archive: usize,
124    pub documents: DocumentConfig,
125    pub readiness: ReadinessConfig,
126}
127
128impl Default for OpsConfig {
129    fn default() -> Self {
130        Self {
131            backup_dir: None,
132            consolidation_min_cluster_size: crate::maintenance::MIN_CLUSTER_SIZE,
133            consolidation_similarity: crate::maintenance::SIMILARITY_THRESHOLD,
134            dedup_batch_size: 1000,
135            max_forgets_per_archive: crate::maintenance::MAX_FORGETS_PER_SWEEP,
136            documents: DocumentConfig::default(),
137            readiness: ReadinessConfig::default(),
138        }
139    }
140}
141
142impl OpsConfig {
143    /// Defaults plus `MENTEDB_BACKUP_DIR`.
144    pub fn from_env() -> Self {
145        Self {
146            backup_dir: std::env::var_os("MENTEDB_BACKUP_DIR")
147                .filter(|v| !v.is_empty())
148                .map(PathBuf::from),
149            ..Self::default()
150        }
151    }
152}
153
154/// The operations settings, read from the environment once.
155pub fn ops_config() -> &'static OpsConfig {
156    static CONFIG: OnceLock<OpsConfig> = OnceLock::new();
157    CONFIG.get_or_init(OpsConfig::from_env)
158}
159
160// ---------------------------------------------------------------------------
161// Routes
162// ---------------------------------------------------------------------------
163
164/// Routes on one database, mounted under `/v1` and `/v1/db/{db}`.
165pub fn routes() -> Router<Arc<AppState>> {
166    Router::new()
167        .route("/maintenance/decay", post(maintenance_decay))
168        .route("/maintenance/archive", post(maintenance_archive))
169        .route("/maintenance/consolidate", post(maintenance_consolidate))
170        .route("/maintenance/sweep", post(maintenance_sweep))
171        .route("/enrich", post(enrich))
172        .route("/documents", post(store_document))
173        .route("/documents/{document_id}", delete(delete_document))
174        .route("/backup", post(create_backup))
175        .route("/backups", get(list_backups))
176}
177
178/// Routes outside any one database (database management, readiness).
179pub fn global_routes() -> Router<Arc<AppState>> {
180    Router::new()
181        .route("/v1/databases", get(list_databases).post(create_database))
182        .route("/v1/databases/{name}", delete(delete_database))
183        .route("/v1/ready", get(ready))
184}
185
186// ---------------------------------------------------------------------------
187// Helpers
188// ---------------------------------------------------------------------------
189
190/// Admits only admins: a token with the admin claim, or the configured admin
191/// key in `x-api-key` / `Authorization: Bearer`. Listed before [`Db`] in a
192/// handler so a refused request never opens or creates a database.
193pub struct AdminGuard;
194
195impl FromRequestParts<Arc<AppState>> for AdminGuard {
196    type Rejection = ApiError;
197
198    async fn from_request_parts(
199        parts: &mut Parts,
200        state: &Arc<AppState>,
201    ) -> Result<Self, ApiError> {
202        if parts
203            .extensions
204            .get::<AuthenticatedAgent>()
205            .is_some_and(|a| a.admin)
206        {
207            return Ok(Self);
208        }
209        crate::auth::admin_authorized(&parts.headers, &state.admin_key)?;
210        Ok(Self)
211    }
212}
213
214/// Run blocking engine work off the async workers.
215async fn blocking<T, F>(what: &'static str, f: F) -> Result<T, ApiError>
216where
217    T: Send + 'static,
218    F: FnOnce() -> Result<T, ApiError> + Send + 'static,
219{
220    tokio::task::spawn_blocking(f)
221        .await
222        .map_err(|e| ApiError::Internal(format!("{what} task failed: {e}")))?
223}
224
225fn internal(what: &str) -> impl FnOnce(mentedb_core::MenteError) -> ApiError + '_ {
226    move |e| ApiError::Internal(format!("{what} failed: {e}"))
227}
228
229fn now_micros() -> u64 {
230    SystemTime::now()
231        .duration_since(UNIX_EPOCH)
232        .unwrap_or_default()
233        .as_micros() as u64
234}
235
236// ---------------------------------------------------------------------------
237// POST /maintenance/*
238// ---------------------------------------------------------------------------
239
240/// Age salience. The engine derives decay on read, so this persists nothing
241/// and reports what the engine reports.
242async fn maintenance_decay(db: Db) -> Result<Json<Value>, ApiError> {
243    let handle = db.handle.clone();
244    let decayed = blocking("decay", move || {
245        handle.apply_decay_global().map_err(internal("decay"))
246    })
247    .await?;
248    Ok(Json(json!({ "database": db.name, "decayed": decayed })))
249}
250
251/// Evaluate every memory for archival and forget the ones flagged for
252/// deletion, capped per run, the same way the maintenance sweep does.
253/// Memories flagged for archiving or consolidation are only counted.
254async fn maintenance_archive(db: Db) -> Result<Json<Value>, ApiError> {
255    let handle = db.handle.clone();
256    let cap = ops_config().max_forgets_per_archive;
257    let report = blocking("archive", move || {
258        let decisions = handle
259            .evaluate_archival_global()
260            .map_err(internal("archival"))?;
261        let evaluated = decisions.len();
262        let (mut keep, mut archive, mut consolidate, mut delete) = (0usize, 0usize, 0, 0);
263        let mut forgotten = 0usize;
264        for (id, decision) in decisions {
265            match decision {
266                ArchivalDecision::Keep => keep += 1,
267                ArchivalDecision::Archive => archive += 1,
268                ArchivalDecision::Consolidate(_) => consolidate += 1,
269                ArchivalDecision::Delete => {
270                    delete += 1;
271                    if forgotten < cap && handle.forget(id).is_ok() {
272                        forgotten += 1;
273                    }
274                }
275            }
276        }
277        handle.flush().map_err(internal("flush"))?;
278        Ok(json!({
279            "evaluated": evaluated,
280            "keep": keep,
281            "flagged_archive": archive,
282            "flagged_consolidate": consolidate,
283            "flagged_delete": delete,
284            "forgotten": forgotten,
285            "capped": delete > forgotten,
286        }))
287    })
288    .await?;
289    Ok(Json(with_database(&db.name, report)))
290}
291
292/// Merge duplicates: first the paraphrase dedup pass (the newer restatement
293/// is folded into the older memory), then the sweep's cluster consolidation
294/// (near duplicates merged into one memory, sources invalidated and linked).
295async fn maintenance_consolidate(db: Db) -> Result<Json<Value>, ApiError> {
296    let handle = db.handle.clone();
297    let cfg = ops_config();
298    let (batch, min_cluster, similarity) = (
299        cfg.dedup_batch_size.max(1),
300        cfg.consolidation_min_cluster_size,
301        cfg.consolidation_similarity,
302    );
303    let report = blocking("consolidate", move || {
304        let ids = handle.memory_ids();
305        let mut duplicates_merged = 0usize;
306        for chunk in ids.chunks(batch) {
307            duplicates_merged += handle.dedup_sweep_ids(chunk).map_err(internal("dedup"))?;
308        }
309        let candidates = handle
310            .find_consolidation_candidates(min_cluster, similarity)
311            .map_err(internal("consolidation"))?;
312        let clusters_found = candidates.len();
313        let clusters_consolidated = candidates
314            .iter()
315            .filter(|c| handle.consolidate_cluster(&c.memories).is_ok())
316            .count();
317        handle.flush().map_err(internal("flush"))?;
318        Ok(json!({
319            "duplicates_merged": duplicates_merged,
320            "clusters_found": clusters_found,
321            "clusters_consolidated": clusters_consolidated,
322        }))
323    })
324    .await?;
325    Ok(Json(with_database(&db.name, report)))
326}
327
328/// The full maintenance sweep the scheduler runs.
329async fn maintenance_sweep(db: Db) -> Result<Json<Value>, ApiError> {
330    let handle = db.handle.clone();
331    let report = blocking("sweep", move || Ok(crate::maintenance::run_sweep(&handle))).await?;
332    Ok(Json(json!({
333        "database": db.name,
334        "consolidated": report.consolidated,
335        "decayed": report.decayed,
336        "forgotten": report.forgotten,
337    })))
338}
339
340fn with_database(name: &str, mut report: Value) -> Value {
341    if let Value::Object(map) = &mut report {
342        map.insert("database".into(), Value::String(name.to_string()));
343    }
344    report
345}
346
347// ---------------------------------------------------------------------------
348// POST /enrich
349// ---------------------------------------------------------------------------
350
351/// Run the enrichment pipeline over this database's unenriched episodic
352/// memories, then entity linking, communities and the user model.
353async fn enrich(State(state): State<Arc<AppState>>, db: Db) -> Result<Json<Value>, ApiError> {
354    let config = state.extraction_config.clone().ok_or_else(|| {
355        ApiError::ServiceUnavailable(
356            "enrichment needs an LLM: start the server with --llm-provider or \
357             MENTEDB_LLM_PROVIDER"
358                .into(),
359        )
360    })?;
361    let embedder = state.databases.embedder().ok_or_else(|| {
362        ApiError::ServiceUnavailable(
363            "enrichment needs an embedder: start the server with an embedding provider".into(),
364        )
365    })?;
366    // Extraction runs on the extraction model; the judge that links
367    // entities, summarizes communities and models the user reads stored
368    // memories, so it runs on the reader model.
369    let provider = mentedb_extraction::HttpExtractionProvider::new(config.reader_config())
370        .map_err(|e| ApiError::ServiceUnavailable(format!("LLM provider unavailable: {e}")))?;
371    let llm = mentedb::cognitive::CognitiveLlmService::new(
372        mentedb_extraction::ExtractionLlmJudge::new(provider),
373    );
374    let handle = db.handle.clone();
375    // Enrichment runs on its own bounded pool so long LLM pipelines never
376    // hold the threads that serve reads.
377    let result = crate::hosting::run_on_enrichment_pool(async move {
378        // The engine compares this watermark with memory creation times, so
379        // the next run only considers memories stored after this one.
380        let watermark = now_micros();
381        let result = mentedb::enrichment::run_enrichment(
382            &handle,
383            config,
384            &*embedder,
385            Some(&llm),
386            watermark,
387            false,
388        )
389        .await;
390        handle.flush().map_err(internal("flush"))?;
391        Ok::<_, ApiError>(result)
392    })
393    .await??;
394    Ok(Json(json!({
395        "database": db.name,
396        "memories_stored": result.memories_stored,
397        "edges_created": result.edges_created,
398        "entities_extracted": result.entities_extracted,
399        "duplicates_skipped": result.duplicates_skipped,
400        "contradictions_found": result.contradictions_found,
401        "sync_linked": result.sync_linked,
402        "llm_linked": result.llm_linked,
403        "communities_created": result.communities_created,
404        "user_model_updated": result.user_model_updated,
405    })))
406}
407
408// ---------------------------------------------------------------------------
409// POST /documents, DELETE /documents/{document_id}
410// ---------------------------------------------------------------------------
411
412/// Tag carried by every chunk of a document.
413fn document_tag(document_id: &Uuid) -> String {
414    format!("doc:{document_id}")
415}
416
417fn optional_str<'a>(req: &'a Value, field: &str) -> Result<Option<&'a str>, ApiError> {
418    match req.get(field) {
419        None | Some(Value::Null) => Ok(None),
420        Some(Value::String(s)) => Ok(Some(s.as_str())),
421        Some(_) => Err(ApiError::BadRequest(format!("'{field}' must be a string"))),
422    }
423}
424
425fn optional_usize(req: &Value, field: &str) -> Result<Option<usize>, ApiError> {
426    match req.get(field) {
427        None | Some(Value::Null) => Ok(None),
428        Some(v) => v
429            .as_u64()
430            .map(|n| Some(n as usize))
431            .ok_or_else(|| ApiError::BadRequest(format!("'{field}' must be a whole number"))),
432    }
433}
434
435/// Load a document into memory as overlapping chunks.
436///
437/// Body: `content` (required), optional `title`, `source`, `agent_id`,
438/// `user_id`, `tags`, `memory_type` (default `semantic`), `chunk_chars`,
439/// `overlap_chars`.
440async fn store_document(
441    agent: Option<Extension<AuthenticatedAgent>>,
442    db: Db,
443    Json(req): Json<Value>,
444) -> Result<impl IntoResponse, ApiError> {
445    let cfg = &ops_config().documents;
446    let content = req
447        .get("content")
448        .and_then(|v| v.as_str())
449        .filter(|s| !s.trim().is_empty())
450        .ok_or_else(|| ApiError::BadRequest("missing or empty 'content'".into()))?;
451    let title = optional_str(&req, "title")?.map(String::from);
452    let source = optional_str(&req, "source")?.map(String::from);
453
454    let agent_id = match crate::handlers::parse_optional_uuid(&req, "agent_id")? {
455        Some(id) => {
456            let id = AgentId(id);
457            crate::handlers::check_body_agent(&agent, id)?;
458            id
459        }
460        None => match &agent {
461            Some(Extension(authed)) => authed.agent()?.unwrap_or_else(AgentId::nil),
462            None => AgentId::nil(),
463        },
464    };
465    let user_id = crate::handlers::parse_optional_uuid(&req, "user_id")?
466        .map(UserId)
467        .unwrap_or_else(UserId::nil);
468    let memory_type = if req.get("memory_type").is_some_and(|v| !v.is_null()) {
469        crate::handlers::parse_memory_type(&req)?
470    } else {
471        MemoryType::Semantic
472    };
473    let mut tags: Vec<String> = match req.get("tags") {
474        None | Some(Value::Null) => vec![],
475        Some(Value::Array(arr)) => arr
476            .iter()
477            .map(|v| {
478                v.as_str()
479                    .map(String::from)
480                    .ok_or_else(|| ApiError::BadRequest("'tags' must be strings".into()))
481            })
482            .collect::<Result<_, _>>()?,
483        Some(_) => return Err(ApiError::BadRequest("'tags' must be an array".into())),
484    };
485
486    let chunk_chars = optional_usize(&req, "chunk_chars")?.unwrap_or(cfg.chunk_chars);
487    let overlap_chars = optional_usize(&req, "overlap_chars")?.unwrap_or(cfg.overlap_chars);
488    if chunk_chars < cfg.min_chunk_chars || chunk_chars > cfg.max_chunk_chars {
489        return Err(ApiError::BadRequest(format!(
490            "'chunk_chars' must be between {} and {}",
491            cfg.min_chunk_chars, cfg.max_chunk_chars
492        )));
493    }
494    let max_overlap = (chunk_chars as f32 * cfg.max_overlap_ratio) as usize;
495    if overlap_chars > max_overlap {
496        return Err(ApiError::BadRequest(format!(
497            "'overlap_chars' must be at most {max_overlap} for chunks of {chunk_chars}"
498        )));
499    }
500
501    let chunks = chunk_text(
502        content,
503        chunk_chars,
504        overlap_chars,
505        &cfg.sentence_terminators,
506    );
507    if chunks.len() > cfg.max_chunks {
508        return Err(ApiError::BadRequest(format!(
509            "document splits into {} chunks, more than the limit of {}",
510            chunks.len(),
511            cfg.max_chunks
512        )));
513    }
514
515    let document_id = Uuid::new_v4();
516    let doc_tag = document_tag(&document_id);
517    if !tags.contains(&doc_tag) {
518        tags.push(doc_tag);
519    }
520    let handle = db.handle.clone();
521    let chunk_count = chunks.len();
522    let ids = blocking("store document", move || {
523        let texts: Vec<&str> = chunks.iter().map(String::as_str).collect();
524        let embeddings = handle
525            .embed_batch(&texts)
526            .map_err(|e| ApiError::ServiceUnavailable(format!("embedding failed: {e}")))?;
527        let now = now_micros();
528        let nodes: Vec<MemoryNode> = chunks
529            .iter()
530            .enumerate()
531            .map(|(i, text)| {
532                let mut attributes = HashMap::new();
533                attributes.insert(
534                    "document_id".to_string(),
535                    AttributeValue::String(document_id.to_string()),
536                );
537                attributes.insert("chunk_index".to_string(), AttributeValue::Integer(i as i64));
538                attributes.insert(
539                    "chunk_count".to_string(),
540                    AttributeValue::Integer(chunk_count as i64),
541                );
542                if let Some(t) = &title {
543                    attributes.insert("title".to_string(), AttributeValue::String(t.clone()));
544                }
545                if let Some(s) = &source {
546                    attributes.insert("source".to_string(), AttributeValue::String(s.clone()));
547                }
548                MemoryNode {
549                    id: MemoryId::new(),
550                    agent_id,
551                    user_id,
552                    memory_type,
553                    embedding: embeddings
554                        .as_ref()
555                        .and_then(|e| e.get(i).cloned())
556                        .unwrap_or_default(),
557                    content: text.clone(),
558                    created_at: now,
559                    accessed_at: now,
560                    access_count: 0,
561                    salience: 0.5,
562                    confidence: 1.0,
563                    space_id: SpaceId::nil(),
564                    attributes,
565                    tags: tags.clone(),
566                    valid_from: None,
567                    valid_until: None,
568                    context: None,
569                }
570            })
571            .collect();
572        let ids = handle.store_batch(nodes).map_err(internal("store"))?;
573        handle.flush().map_err(internal("flush"))?;
574        Ok(ids)
575    })
576    .await?;
577
578    Ok((
579        StatusCode::CREATED,
580        Json(json!({
581            "database": db.name,
582            "document_id": document_id.to_string(),
583            "chunk_count": ids.len(),
584            "chunk_ids": ids.iter().map(|id| id.to_string()).collect::<Vec<_>>(),
585        })),
586    ))
587}
588
589/// Forget every chunk of a document.
590async fn delete_document(
591    agent: Option<Extension<AuthenticatedAgent>>,
592    db: Db,
593    Path(params): Path<HashMap<String, String>>,
594) -> Result<Json<Value>, ApiError> {
595    let document_id: Uuid = params
596        .get("document_id")
597        .ok_or_else(|| ApiError::BadRequest("missing document id".into()))?
598        .parse()
599        .map_err(|_| ApiError::BadRequest("invalid document id".into()))?;
600    let handle = db.handle.clone();
601    let deleted = blocking("delete document", move || {
602        let ids = handle.memory_ids_with_tag(&document_tag(&document_id));
603        if ids.is_empty() {
604            return Err(ApiError::NotFound(format!(
605                "document {document_id} not found"
606            )));
607        }
608        // Check every chunk before forgetting any, so a refused request
609        // leaves the document whole.
610        if agent.is_some() {
611            for id in &ids {
612                if let Ok(node) = handle.get_memory(*id) {
613                    crate::handlers::check_owner(&agent, &node)?;
614                }
615            }
616        }
617        for id in &ids {
618            handle.forget(*id).map_err(internal("forget"))?;
619        }
620        handle.flush().map_err(internal("flush"))?;
621        Ok(ids.len())
622    })
623    .await?;
624    Ok(Json(json!({
625        "database": db.name,
626        "document_id": document_id.to_string(),
627        "deleted": deleted,
628    })))
629}
630
631/// Split text into chunks of at most `chunk_chars` characters. Paragraphs
632/// (separated by blank lines) are kept whole when they fit; longer ones are
633/// split on sentence boundaries, and sentences longer than a chunk on word
634/// boundaries. Each chunk after the first starts with up to `overlap_chars`
635/// characters from the end of the previous one, cut at a word boundary.
636pub fn chunk_text(
637    text: &str,
638    chunk_chars: usize,
639    overlap_chars: usize,
640    terminators: &[char],
641) -> Vec<String> {
642    let chunk_chars = chunk_chars.max(1);
643    let overlap_chars = overlap_chars.min(chunk_chars / 2);
644    // Room left for new text once the overlap and a separator are in place.
645    let max_unit = chunk_chars.saturating_sub(overlap_chars + 2).max(1);
646
647    // (unit, starts a new paragraph)
648    let mut units: Vec<(String, bool)> = Vec::new();
649    for paragraph in paragraphs(text) {
650        if char_len(&paragraph) <= max_unit {
651            units.push((paragraph, true));
652            continue;
653        }
654        let mut first = true;
655        for sentence in sentences(&paragraph, terminators) {
656            for piece in split_long(&sentence, max_unit) {
657                units.push((piece, first));
658                first = false;
659            }
660        }
661    }
662
663    let mut chunks = Vec::new();
664    let mut current = String::new();
665    for (unit, new_paragraph) in units {
666        let sep = if new_paragraph { "\n\n" } else { " " };
667        if current.is_empty() {
668            current = unit;
669        } else if char_len(&current) + sep.len() + char_len(&unit) <= chunk_chars {
670            current.push_str(sep);
671            current.push_str(&unit);
672        } else {
673            let tail = overlap_tail(&current, overlap_chars);
674            chunks.push(std::mem::take(&mut current));
675            if !tail.is_empty() {
676                current.push_str(&tail);
677                current.push_str(sep);
678            }
679            current.push_str(&unit);
680        }
681    }
682    if !current.is_empty() {
683        chunks.push(current);
684    }
685    chunks
686}
687
688fn char_len(s: &str) -> usize {
689    s.chars().count()
690}
691
692/// Paragraphs separated by blank lines, trimmed, empty ones dropped.
693fn paragraphs(text: &str) -> Vec<String> {
694    let mut out = Vec::new();
695    let mut lines: Vec<&str> = Vec::new();
696    for line in text.lines() {
697        if line.trim().is_empty() {
698            if !lines.is_empty() {
699                out.push(lines.join("\n").trim().to_string());
700                lines.clear();
701            }
702        } else {
703            lines.push(line);
704        }
705    }
706    if !lines.is_empty() {
707        out.push(lines.join("\n").trim().to_string());
708    }
709    out.retain(|p| !p.is_empty());
710    out
711}
712
713/// Sentences: a terminator followed by whitespace ends one.
714fn sentences(paragraph: &str, terminators: &[char]) -> Vec<String> {
715    let mut out = Vec::new();
716    let mut current = String::new();
717    let mut chars = paragraph.chars().peekable();
718    while let Some(c) = chars.next() {
719        current.push(c);
720        if terminators.contains(&c) && chars.peek().is_none_or(|n| n.is_whitespace()) {
721            let s = current.trim();
722            if !s.is_empty() {
723                out.push(s.to_string());
724            }
725            current.clear();
726        }
727    }
728    let s = current.trim();
729    if !s.is_empty() {
730        out.push(s.to_string());
731    }
732    out
733}
734
735/// Pieces of at most `max` characters, broken at whitespace when possible.
736fn split_long(text: &str, max: usize) -> Vec<String> {
737    let mut out = Vec::new();
738    let mut current = String::new();
739    for word in text.split_whitespace() {
740        let mut word = word.to_string();
741        // A single word longer than a piece is cut by characters.
742        while char_len(&word) > max {
743            if !current.is_empty() {
744                out.push(std::mem::take(&mut current));
745            }
746            let head: String = word.chars().take(max).collect();
747            word = word.chars().skip(max).collect();
748            out.push(head);
749        }
750        if word.is_empty() {
751            continue;
752        }
753        if current.is_empty() {
754            current = word;
755        } else if char_len(&current) + 1 + char_len(&word) <= max {
756            current.push(' ');
757            current.push_str(&word);
758        } else {
759            out.push(std::mem::replace(&mut current, word));
760        }
761    }
762    if !current.is_empty() {
763        out.push(current);
764    }
765    out
766}
767
768/// The last `overlap` characters of `chunk`, starting at a word boundary.
769fn overlap_tail(chunk: &str, overlap: usize) -> String {
770    if overlap == 0 {
771        return String::new();
772    }
773    let total = char_len(chunk);
774    if total <= overlap {
775        return chunk.trim().to_string();
776    }
777    let start = chunk
778        .char_indices()
779        .nth(total - overlap)
780        .map(|(i, _)| i)
781        .unwrap_or(0);
782    let tail = &chunk[start..];
783    // Drop a partial leading word unless the tail is one single word.
784    let preceded_by_space = chunk[..start].ends_with(char::is_whitespace);
785    let tail = if preceded_by_space {
786        tail
787    } else {
788        match tail.find(char::is_whitespace) {
789            Some(i) => &tail[i..],
790            None => tail,
791        }
792    };
793    tail.trim().to_string()
794}
795
796// ---------------------------------------------------------------------------
797// POST /backup, GET /backups
798// ---------------------------------------------------------------------------
799
800/// File extension the storage layer lists as a backup.
801const BACKUP_EXTENSION: &str = "mentebackup";
802
803fn backup_root(state: &AppState) -> PathBuf {
804    ops_config()
805        .backup_dir
806        .clone()
807        .unwrap_or_else(|| state.databases.root().join("backups"))
808}
809
810/// `YYYYMMDDTHHMMSS.mmmZ` in UTC.
811fn utc_stamp(at: SystemTime) -> String {
812    let since = at.duration_since(UNIX_EPOCH).unwrap_or_default();
813    let secs = since.as_secs();
814    let (days, rem) = ((secs / 86_400) as i64, secs % 86_400);
815    let (year, month, day) = civil_from_days(days);
816    format!(
817        "{year:04}{month:02}{day:02}T{:02}{:02}{:02}.{:03}Z",
818        rem / 3600,
819        (rem % 3600) / 60,
820        rem % 60,
821        since.subsec_millis()
822    )
823}
824
825/// Proleptic Gregorian date for days since 1970-01-01 (Howard Hinnant's
826/// civil_from_days).
827fn civil_from_days(days: i64) -> (i64, u32, u32) {
828    let z = days + 719_468;
829    let era = z.div_euclid(146_097);
830    let doe = z.rem_euclid(146_097);
831    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
832    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
833    let mp = (5 * doy + 2) / 153;
834    let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
835    let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
836    let year = yoe + era * 400 + i64::from(month <= 2);
837    (year, month, day)
838}
839
840/// Copy `src` into `dst`, skipping any path under one of `exclude`.
841fn copy_tree(src: &FsPath, dst: &FsPath, exclude: &[PathBuf]) -> std::io::Result<()> {
842    std::fs::create_dir_all(dst)?;
843    for entry in std::fs::read_dir(src)? {
844        let entry = entry?;
845        let path = entry.path();
846        let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
847        if exclude.iter().any(|x| canonical.starts_with(x)) {
848            continue;
849        }
850        let target = dst.join(entry.file_name());
851        if entry.file_type()?.is_dir() {
852            copy_tree(&path, &target, exclude)?;
853        } else {
854            std::fs::copy(&path, &target)?;
855        }
856    }
857    Ok(())
858}
859
860/// Flush the database and write a full backup file to
861/// `<backup_dir>/<database>/<database>-<UTC time>.mentebackup`.
862///
863/// Writes that land while the files are read may or may not be included;
864/// for a snapshot at an exact point, pause writers first.
865async fn create_backup(
866    _guard: MaintenanceGuard,
867    State(state): State<Arc<AppState>>,
868    db: Db,
869    body: axum::body::Bytes,
870) -> Result<impl IntoResponse, ApiError> {
871    let requested = if body.iter().all(|b| b.is_ascii_whitespace()) {
872        None
873    } else {
874        let req: Value = serde_json::from_slice(&body)
875            .map_err(|e| ApiError::BadRequest(format!("invalid JSON: {e}")))?;
876        match req.get("name") {
877            None | Some(Value::Null) => None,
878            Some(Value::String(s)) => Some(backup_file_name(s)?),
879            Some(_) => return Err(ApiError::BadRequest("'name' must be a string".into())),
880        }
881    };
882    let db_dir = state.databases.path_of(&db.name)?;
883    let root = backup_root(&state);
884    let data_root = state.databases.root().to_path_buf();
885    let handle = db.handle.clone();
886    let name = db.name.clone();
887    let (file, manifest) = blocking("backup", move || {
888        handle.flush_full().map_err(internal("flush"))?;
889        let io = |what: &'static str| {
890            move |e: std::io::Error| ApiError::Internal(format!("{what}: {e}"))
891        };
892        let dir = root.join(&name);
893        std::fs::create_dir_all(&dir).map_err(io("create backup directory"))?;
894        let root_c = root
895            .canonicalize()
896            .map_err(io("resolve backup directory"))?;
897        let db_c = db_dir
898            .canonicalize()
899            .map_err(io("resolve database directory"))?;
900        if db_c.starts_with(&root_c) {
901            return Err(ApiError::Internal(
902                "the backup directory must not contain the database directory".into(),
903            ));
904        }
905        let file = match &requested {
906            Some(rel) => rel.clone(),
907            None => format!("{name}-{}.{BACKUP_EXTENSION}", utc_stamp(SystemTime::now())),
908        };
909        let path = dir.join(&file);
910        // A chosen name may hold subdirectories; they must stay inside this
911        // database's backup directory.
912        if let Some(parent) = path.parent() {
913            std::fs::create_dir_all(parent).map_err(io("create backup directory"))?;
914            let parent_c = parent
915                .canonicalize()
916                .map_err(io("resolve backup directory"))?;
917            let dir_c = dir.canonicalize().map_err(io("resolve backup directory"))?;
918            if !parent_c.starts_with(&dir_c) {
919                return Err(ApiError::BadRequest(
920                    "backup name escapes the backup directory".into(),
921                ));
922            }
923        }
924        if path.exists() {
925            return Err(ApiError::Conflict(format!(
926                "backup {file} already exists; retry"
927            )));
928        }
929
930        // The default database's directory also holds the named databases and,
931        // by default, the backups; those are left out of its backup.
932        let mut exclude = vec![root_c];
933        if name == DEFAULT_DB {
934            let dbs = data_root.join("dbs");
935            exclude.push(dbs.canonicalize().unwrap_or(dbs));
936        }
937        exclude.retain(|x| x.starts_with(&db_c));
938
939        let manifest = if exclude.is_empty() {
940            BackupManager::create_backup(&db_dir, &path).map_err(internal("backup"))?
941        } else {
942            let staging = dir.join(format!(".staging-{}", Uuid::new_v4()));
943            let staged = copy_tree(&db_dir, &staging, &exclude)
944                .map_err(io("stage backup"))
945                .and_then(|_| {
946                    BackupManager::create_backup(&staging, &path).map_err(internal("backup"))
947                });
948            let _ = std::fs::remove_dir_all(&staging);
949            staged?
950        };
951        Ok((file, manifest))
952    })
953    .await?;
954    Ok((
955        StatusCode::CREATED,
956        Json(json!({
957            "database": db.name,
958            "file": file,
959            "manifest": manifest,
960        })),
961    ))
962}
963
964/// Longest caller chosen backup name.
965const MAX_BACKUP_NAME: usize = 200;
966
967/// Check a caller chosen backup name and give the file name to write: a
968/// relative path of segments made of letters, digits, `-`, `_` and `.`,
969/// separated by `/`, with no empty, `.` or `..` segment and no leading `/`.
970/// The backup extension is appended when missing. The file lands under
971/// `<backup dir>/<database>/`.
972pub fn backup_file_name(name: &str) -> Result<String, ApiError> {
973    let bad = |why: &str| ApiError::BadRequest(format!("invalid backup name '{name}': {why}"));
974    if name.is_empty() || name.len() > MAX_BACKUP_NAME {
975        return Err(bad("use 1 to 200 characters"));
976    }
977    if name.starts_with('/') {
978        return Err(bad("absolute paths are not allowed"));
979    }
980    for segment in name.split('/') {
981        if segment.is_empty() || segment == "." || segment == ".." {
982            return Err(bad("empty, '.' and '..' segments are not allowed"));
983        }
984        if !segment
985            .chars()
986            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
987        {
988            return Err(bad("use letters, digits, '-', '_', '.' and '/'"));
989        }
990    }
991    let suffix = format!(".{BACKUP_EXTENSION}");
992    Ok(if name.ends_with(&suffix) {
993        name.to_string()
994    } else {
995        format!("{name}{suffix}")
996    })
997}
998
999/// Admits a database maintenance call (drop, backup) on the database in the
1000/// path: the admin key, an admin token, or a token with the maintenance scope
1001/// naming that database.
1002pub struct MaintenanceGuard;
1003
1004impl FromRequestParts<Arc<AppState>> for MaintenanceGuard {
1005    type Rejection = ApiError;
1006
1007    async fn from_request_parts(
1008        parts: &mut Parts,
1009        state: &Arc<AppState>,
1010    ) -> Result<Self, ApiError> {
1011        use axum::extract::RawPathParams;
1012        let params = RawPathParams::from_request_parts(parts, state)
1013            .await
1014            .map_err(|e| ApiError::BadRequest(e.to_string()))?;
1015        let name = params
1016            .iter()
1017            .find(|(k, _)| *k == "db")
1018            .map(|(_, v)| v.to_string())
1019            .unwrap_or_else(|| DEFAULT_DB.to_string());
1020        crate::hosting::authorize_maintenance(
1021            state,
1022            &parts.headers,
1023            parts.extensions.get::<AuthenticatedAgent>(),
1024            &name,
1025        )?;
1026        Ok(Self)
1027    }
1028}
1029
1030/// Backups of this database, newest first.
1031async fn list_backups(
1032    _admin: AdminGuard,
1033    State(state): State<Arc<AppState>>,
1034    db: Db,
1035) -> Result<Json<Value>, ApiError> {
1036    let dir = backup_root(&state).join(&db.name);
1037    let backups = blocking("list backups", move || {
1038        let mut found = Vec::new();
1039        let entries = match std::fs::read_dir(&dir) {
1040            Ok(e) => e,
1041            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(found),
1042            Err(e) => return Err(ApiError::Internal(format!("list backups: {e}"))),
1043        };
1044        for entry in entries.flatten() {
1045            let path = entry.path();
1046            if path.extension().and_then(|e| e.to_str()) != Some(BACKUP_EXTENSION) {
1047                continue;
1048            }
1049            if let Ok(manifest) = BackupManager::read_manifest(&path) {
1050                found.push((entry.file_name().to_string_lossy().into_owned(), manifest));
1051            }
1052        }
1053        found.sort_by_key(|(_, m)| std::cmp::Reverse(m.created_at));
1054        Ok(found)
1055    })
1056    .await?;
1057    let backups: Vec<Value> = backups
1058        .into_iter()
1059        .map(|(file, manifest)| json!({ "file": file, "manifest": manifest }))
1060        .collect();
1061    Ok(Json(json!({ "database": db.name, "backups": backups })))
1062}
1063
1064// ---------------------------------------------------------------------------
1065// /v1/databases
1066// ---------------------------------------------------------------------------
1067
1068/// The default database and every named one on disk.
1069async fn list_databases(
1070    _admin: AdminGuard,
1071    State(state): State<Arc<AppState>>,
1072) -> Result<Json<Value>, ApiError> {
1073    let dbs = state.databases.clone();
1074    let (names, open) = blocking("list databases", move || {
1075        let open: Vec<String> = dbs.open_handles().into_iter().map(|(n, _)| n).collect();
1076        Ok((dbs.list(), open))
1077    })
1078    .await?;
1079    Ok(Json(json!({
1080        "default": DEFAULT_DB,
1081        "databases": names,
1082        "open": open,
1083    })))
1084}
1085
1086/// Create a named database. Body: `{"name": "..."}`.
1087async fn create_database(
1088    _admin: AdminGuard,
1089    State(state): State<Arc<AppState>>,
1090    Json(req): Json<Value>,
1091) -> Result<impl IntoResponse, ApiError> {
1092    let name = req
1093        .get("name")
1094        .and_then(|v| v.as_str())
1095        .ok_or_else(|| ApiError::BadRequest("missing 'name'".into()))?
1096        .to_string();
1097    validate_name(&name)?;
1098    let dbs = state.databases.clone();
1099    let created = name.clone();
1100    blocking("create database", move || {
1101        if dbs.exists(&created) {
1102            return Err(ApiError::Conflict(format!(
1103                "database '{created}' already exists"
1104            )));
1105        }
1106        dbs.get(&created, true).map(|_| ())
1107    })
1108    .await?;
1109    Ok((
1110        StatusCode::CREATED,
1111        Json(json!({ "name": name, "status": "created" })),
1112    ))
1113}
1114
1115/// Close and permanently delete a named database.
1116async fn delete_database(
1117    _admin: AdminGuard,
1118    State(state): State<Arc<AppState>>,
1119    Path(name): Path<String>,
1120) -> Result<Json<Value>, ApiError> {
1121    validate_name(&name)?;
1122    let dbs = state.databases.clone();
1123    let target = name.clone();
1124    blocking("delete database", move || dbs.delete(&target)).await?;
1125    Ok(Json(json!({ "name": name, "status": "deleted" })))
1126}
1127
1128// ---------------------------------------------------------------------------
1129// GET /v1/ready
1130// ---------------------------------------------------------------------------
1131
1132/// The last embed check for one embedder. The `Weak` keeps the embedder's
1133/// allocation from being reused, so a pointer match is always the same one.
1134struct EmbedProbe {
1135    embedder: Weak<dyn EmbeddingProvider>,
1136    checked_at: Instant,
1137    result: Result<(), String>,
1138}
1139
1140static EMBED_PROBES: Mutex<Vec<EmbedProbe>> = Mutex::new(Vec::new());
1141
1142async fn check_embedder(
1143    embedder: Arc<dyn EmbeddingProvider>,
1144    cfg: &ReadinessConfig,
1145) -> Result<(), String> {
1146    let weak = Arc::downgrade(&embedder);
1147    {
1148        let mut probes = EMBED_PROBES.lock().unwrap_or_else(|e| e.into_inner());
1149        probes.retain(|p| p.embedder.strong_count() > 0);
1150        if let Some(p) = probes.iter().find(|p| Weak::ptr_eq(&p.embedder, &weak))
1151            && p.checked_at.elapsed() < cfg.embed_check_interval
1152        {
1153            return p.result.clone();
1154        }
1155    }
1156    let text = cfg.probe_text.clone();
1157    let probe = tokio::task::spawn_blocking(move || embedder.embed(&text));
1158    let result = match tokio::time::timeout(cfg.check_timeout, probe).await {
1159        Ok(Ok(Ok(v))) if !v.is_empty() => Ok(()),
1160        Ok(Ok(Ok(_))) => Err("embedder returned an empty vector".to_string()),
1161        Ok(Ok(Err(e))) => Err(format!("embed failed: {e}")),
1162        Ok(Err(e)) => Err(format!("embed task failed: {e}")),
1163        Err(_) => Err(format!("embed timed out after {:?}", cfg.check_timeout)),
1164    };
1165    let mut probes = EMBED_PROBES.lock().unwrap_or_else(|e| e.into_inner());
1166    probes.retain(|p| !Weak::ptr_eq(&p.embedder, &weak));
1167    probes.push(EmbedProbe {
1168        embedder: weak,
1169        checked_at: Instant::now(),
1170        result: result.clone(),
1171    });
1172    result
1173}
1174
1175/// Readiness: 200 when the default database answers, the data directory takes
1176/// a write, the probe database (`MENTEDB_READY_PROBE_DB`) opens and locks,
1177/// and, if an embedder is configured, it embeds (checked at most once per
1178/// configured interval; `?embedder=skip` leaves it out). 503 naming the
1179/// failing check otherwise. Liveness is `/v1/live`, which checks nothing.
1180async fn ready(
1181    State(state): State<Arc<AppState>>,
1182    axum::extract::Query(q): axum::extract::Query<HashMap<String, String>>,
1183) -> Response {
1184    let cfg = &ops_config().readiness;
1185    let skip_embedder = q.get("embedder").is_some_and(|v| v == "skip");
1186    let mut checks = serde_json::Map::new();
1187    let mut failed: Vec<&str> = Vec::new();
1188
1189    let db = state.databases.default_db();
1190    let probe = tokio::task::spawn_blocking(move || db.memory_count());
1191    match tokio::time::timeout(cfg.check_timeout, probe).await {
1192        Ok(Ok(_)) => {
1193            checks.insert("database".into(), json!("ok"));
1194        }
1195        Ok(Err(e)) => {
1196            checks.insert("database".into(), json!(format!("error: {e}")));
1197            failed.push("database");
1198        }
1199        Err(_) => {
1200            checks.insert(
1201                "database".into(),
1202                json!(format!("error: timed out after {:?}", cfg.check_timeout)),
1203            );
1204            failed.push("database");
1205        }
1206    }
1207
1208    // The data mount takes writes: create and remove a probe file.
1209    let root = state.databases.root().to_path_buf();
1210    let write_probe = tokio::task::spawn_blocking(move || {
1211        let probe = root.join(format!(".ready-{}", Uuid::new_v4()));
1212        std::fs::write(&probe, b"ok").and_then(|_| std::fs::remove_file(&probe))
1213    });
1214    match tokio::time::timeout(cfg.check_timeout, write_probe).await {
1215        Ok(Ok(Ok(()))) => {
1216            checks.insert("data_dir".into(), json!("ok"));
1217        }
1218        Ok(Ok(Err(e))) => {
1219            checks.insert(
1220                "data_dir".into(),
1221                json!(format!("error: not writable: {e}")),
1222            );
1223            failed.push("data_dir");
1224        }
1225        Ok(Err(e)) => {
1226            checks.insert("data_dir".into(), json!(format!("error: {e}")));
1227            failed.push("data_dir");
1228        }
1229        Err(_) => {
1230            checks.insert(
1231                "data_dir".into(),
1232                json!(format!("error: timed out after {:?}", cfg.check_timeout)),
1233            );
1234            failed.push("data_dir");
1235        }
1236    }
1237
1238    // A named database opens, which takes its directory lock.
1239    if let Some(probe_db) = crate::hosting::hosting_config().ready_probe_db.clone() {
1240        let dbs = state.databases.clone();
1241        let open = tokio::task::spawn_blocking(move || dbs.get(&probe_db, true).map(|_| ()));
1242        match tokio::time::timeout(cfg.check_timeout, open).await {
1243            Ok(Ok(Ok(()))) => {
1244                checks.insert("database_lock".into(), json!("ok"));
1245            }
1246            Ok(Ok(Err(e))) => {
1247                checks.insert("database_lock".into(), json!(format!("error: {e}")));
1248                failed.push("database_lock");
1249            }
1250            Ok(Err(e)) => {
1251                checks.insert("database_lock".into(), json!(format!("error: {e}")));
1252                failed.push("database_lock");
1253            }
1254            Err(_) => {
1255                checks.insert(
1256                    "database_lock".into(),
1257                    json!(format!("error: timed out after {:?}", cfg.check_timeout)),
1258                );
1259                failed.push("database_lock");
1260            }
1261        }
1262    }
1263
1264    match state.databases.embedder() {
1265        None => {
1266            checks.insert("embedder".into(), json!("not_configured"));
1267        }
1268        Some(_) if skip_embedder => {
1269            checks.insert("embedder".into(), json!("skipped"));
1270        }
1271        Some(embedder) => match check_embedder(embedder, cfg).await {
1272            Ok(()) => {
1273                checks.insert("embedder".into(), json!("ok"));
1274            }
1275            Err(e) => {
1276                checks.insert("embedder".into(), json!(format!("error: {e}")));
1277                failed.push("embedder");
1278            }
1279        },
1280    }
1281
1282    if failed.is_empty() {
1283        (
1284            StatusCode::OK,
1285            Json(json!({ "status": "ready", "checks": checks })),
1286        )
1287            .into_response()
1288    } else {
1289        (
1290            StatusCode::SERVICE_UNAVAILABLE,
1291            Json(json!({ "status": "not_ready", "failed": failed, "checks": checks })),
1292        )
1293            .into_response()
1294    }
1295}
1296
1297#[cfg(test)]
1298mod tests {
1299    use super::*;
1300
1301    const TERMS: &[char] = &['.', '!', '?'];
1302
1303    #[test]
1304    fn short_text_is_one_chunk() {
1305        let chunks = chunk_text("One paragraph. Two sentences.", 100, 20, TERMS);
1306        assert_eq!(chunks, vec!["One paragraph. Two sentences.".to_string()]);
1307    }
1308
1309    #[test]
1310    fn chunks_respect_the_limit_and_overlap() {
1311        let sentence = "The quick brown fox jumps over the lazy dog again.";
1312        let paragraph = [sentence; 6].join(" ");
1313        let text = [paragraph.as_str(); 4].join("\n\n");
1314        let chunks = chunk_text(&text, 200, 40, TERMS);
1315        assert!(chunks.len() > 4);
1316        for c in &chunks {
1317            assert!(char_len(c) <= 200, "{} chars: {c}", char_len(c));
1318        }
1319        for pair in chunks.windows(2) {
1320            let tail_word = pair[0].split_whitespace().last().unwrap();
1321            assert!(pair[1].contains(tail_word));
1322        }
1323    }
1324
1325    #[test]
1326    fn a_word_longer_than_a_chunk_is_cut() {
1327        let text = "x".repeat(450);
1328        let chunks = chunk_text(&text, 200, 0, TERMS);
1329        assert!(chunks.iter().all(|c| char_len(c) <= 200));
1330        assert_eq!(chunks.concat(), text);
1331    }
1332
1333    #[test]
1334    fn utc_stamp_formats_known_instants() {
1335        assert_eq!(utc_stamp(UNIX_EPOCH), "19700101T000000.000Z");
1336        let t = UNIX_EPOCH + Duration::from_millis(1_709_210_096_789);
1337        assert_eq!(utc_stamp(t), "20240229T123456.789Z");
1338    }
1339}