1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
//! CLI lifecycle: lazy [`Store`] + [`Config`] + [`LlamaCpp`] managed by an [`IndexState`].
//!
//! Maps to qmd's module-level `let store / getStore()` helpers and the
//! `--index` / local-config resolution in `src/cli/qmd.ts` (lines 119–188).
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result, anyhow};
use rqmd_core::collections::{Config, find_local_config_path, local_db_path, sanitize_index_name};
use rqmd_core::llm::config::{ResolvedModels, resolve_models};
use rqmd_core::llm::llama_cpp::{LlamaCpp, LlamaCppConfig};
use rqmd_core::llm::types::ModelResolutionConfig;
use rqmd_core::paths::config_file_path;
use rqmd_core::store::path::{default_db_path, pwd};
use rqmd_core::store::store_config::sync_config_to_db;
use rqmd_core::{Llm, RqmdStoreOptions, Store};
/// Holds the CLI's index selection plus the lazily-opened [`Store`],
/// [`Config`], and [`LlamaCpp`] handle. One per `rqmd` invocation.
pub struct IndexState {
index_name: String,
db_path_override: Option<PathBuf>,
config_path_override: Option<PathBuf>,
store: Option<Store>,
config: Option<Config>,
llama: Option<Arc<LlamaCpp>>,
}
impl IndexState {
/// Build state from the parsed CLI globals.
pub fn new(index_name: Option<&str>) -> Self {
let (index_name, db_path_override, config_path_override) = match index_name {
Some(name) => (name.to_string(), None, None),
None => match find_local_config_path(&pwd()) {
Some(cfg) => ("index".to_string(), Some(local_db_path(&cfg)), Some(cfg)),
None => ("index".to_string(), None, None),
},
};
Self {
index_name,
db_path_override,
config_path_override,
store: None,
config: None,
llama: None,
}
}
/// The active index name (default `"index"`). Used to annotate output
/// links with `?index=` for non-default indexes (qmd `getActiveIndexName`).
pub fn index_name(&self) -> &str {
&self.index_name
}
/// Switch the active index at runtime, dropping the lazily-opened store and
/// config so the next access reopens against the new index. Mirrors qmd's
/// `setIndexName` + `setConfigIndexName` (`qmd.ts:176-188`), used by `get`
/// to honour a `?index=` carried in a `qmd://` link.
///
/// NOTE: `db_path_override = None` deliberately diverges from qmd's
/// `setIndexName` (which sets the override to the *resolved* path). rqmd
/// resolves lazily in [`Self::db_path`] via `default_db_path(Some(name))`,
/// so `None` correctly opens `<cache>/<name>.sqlite`. It also clears any
/// stale local-config (`.rqmd`) override picked up by [`Self::new`] so the
/// link's index — not the cwd — wins.
pub fn set_index_name(&mut self, name: &str) {
self.index_name = sanitize_index_name(name);
self.db_path_override = None;
self.config_path_override = None;
self.store = None;
self.config = None;
}
/// Path to the SQLite index that will be opened on first use.
pub fn db_path(&self) -> Result<PathBuf> {
if let Some(p) = &self.db_path_override {
return Ok(p.clone());
}
default_db_path(Some(&self.index_name))
.context("resolving default index path (set RQMD_INDEX_PATH or use --index)")
}
/// Build [`RqmdStoreOptions`] for the active index. Used by `rqmd mcp`, which
/// drives the higher-level `RqmdStore` facade (the analog of qmd's
/// `createStore`) rather than the lazy `Store`/`Config`/`LlamaCpp` trio.
///
/// `config_path` is set only when the file exists, mirroring qmd's
/// `existsSync(getConfigPath())` guard in `server.ts:558`. When absent, the
/// store runs DB-only: collections already synced into SQLite by earlier
/// `collection add` / `update` runs remain searchable.
pub fn rqmd_store_options(&self) -> Result<RqmdStoreOptions> {
let db_path = self.db_path()?;
let candidate = match &self.config_path_override {
Some(p) => p.clone(),
None => config_file_path(&self.index_name),
};
let config_path = candidate.is_file().then_some(candidate);
Ok(RqmdStoreOptions {
db_path,
config_path,
config: None,
})
}
/// Open the [`Store`] (lazy). Subsequent calls return the same handle.
pub fn store_mut(&mut self) -> Result<&mut Store> {
if self.store.is_none() {
let path = self.db_path()?;
let store = Store::open(&path)
.with_context(|| format!("opening index at {}", path.display()))?;
self.store = Some(store);
}
Ok(self.store.as_mut().expect("just inserted"))
}
/// Load the YAML [`Config`] (lazy).
pub fn config_mut(&mut self) -> Result<&mut Config> {
if self.config.is_none() {
let cfg = match &self.config_path_override {
Some(p) => Config::from_file(p.clone())
.with_context(|| format!("loading config at {}", p.display()))?,
None => Config::from_index_name(self.index_name.clone())
.context("loading config from default location")?,
};
self.config = Some(cfg);
}
Ok(self.config.as_mut().expect("just inserted"))
}
/// Immutable accessor; assumes [`Self::config_mut`] has already been called
/// to populate the lazy cache. LLM commands trigger that on entry.
fn config(&self) -> Result<&Config> {
self.config
.as_ref()
.ok_or_else(|| anyhow!("config not loaded; call config_mut() first"))
}
/// Translate the YAML `models:` section into a [`ModelResolutionConfig`].
/// Missing fields stay `None`; resolution against env vars and crate
/// defaults happens inside [`LlamaCpp::new`].
fn models_config(&self) -> Result<ModelResolutionConfig> {
let data = self.config()?.data();
Ok(ModelResolutionConfig {
embed: data.models.as_ref().and_then(|m| m.embed.clone()),
generate: data.models.as_ref().and_then(|m| m.generate.clone()),
rerank: data.models.as_ref().and_then(|m| m.rerank.clone()),
})
}
/// Lazily construct (and cache) an [`LlamaCpp`] handle. Subsequent calls
/// return the same `Arc`, so multiple LLM commands invoked in the same
/// process share one worker pool / model cache.
///
/// Model URI resolution (env > YAML > crate default) is delegated to
/// `LlamaCpp::new`; `ci_mode` / `parallelism` etc. come from
/// `LlamaCppConfig::from_env`.
///
/// Note: only the write path (`embed`) currently threads the YAML
/// `models:` section through; the read path (`hybrid_query` /
/// `vector_search_query`) calls `resolve_*_model(None)` internally and
/// sees only env vars and defaults. See the plan's Known Limitations.
pub fn llama_cpp(&mut self) -> Result<Arc<LlamaCpp>> {
if self.llama.is_none() {
// Force the lazy YAML load before we read it.
self.config_mut()?;
let model_cfg = self.models_config()?;
let llama = LlamaCppConfig {
embed_model: model_cfg.embed,
generate_model: model_cfg.generate,
rerank_model: model_cfg.rerank,
..LlamaCppConfig::from_env()
};
self.llama = Some(Arc::new(LlamaCpp::new(llama)));
}
Ok(self.llama.as_ref().expect("just inserted").clone())
}
/// Fully-resolved model URIs (env > YAML > crate default). The single
/// source of truth shared by `pull` (which feeds them to `pull_models`)
/// and any future caller that needs the URIs without instantiating an
/// `LlamaCpp`.
pub fn resolved_model_uris(&mut self) -> Result<ResolvedModels> {
self.config_mut()?;
let cfg = self.models_config()?;
Ok(resolve_models(Some(&cfg)))
}
/// Re-load the YAML and re-sync it into the SQLite `store_collections` /
/// `store_config` tables. Call after any CLI mutation that touches
/// collections or contexts in the config.
pub fn resync_config(&mut self) -> Result<()> {
// Drop and reload the in-memory config from disk so we see the just-saved file.
self.config = None;
let config_snapshot = self.config_mut()?.clone();
let store = self.store_mut()?;
store.with_connection_mut(|conn| sync_config_to_db(conn, &config_snapshot))?;
Ok(())
}
/// Tear down LLM workers if instantiated. Mostly cosmetic for a one-shot
/// CLI (the OS reclaims everything on exit), but matches the
/// [`LlamaCpp`] / SDK contract that callers MUST dispose before drop —
/// Rust has no async `Drop`. Called once from `main.rs::run` at end of
/// dispatch (Ok and Err paths both).
///
/// Note: `dispose` is a method on the [`Llm`] trait, not an inherent
/// method on [`LlamaCpp`], so the trait must be in scope (see the
/// `use rqmd_core::Llm` at the top of this file).
pub async fn close(self) {
if let Some(llama) = self.llama {
llama.dispose().await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn close_is_no_op_when_llama_not_constructed() {
// Lazy state with no LLM yet — close() must short-circuit cleanly.
let state = IndexState::new(None);
assert!(state.llama.is_none());
state.close().await;
}
#[tokio::test]
async fn close_disposes_llama_cpp() {
// Bypass YAML/db setup: inject a ci-mode LlamaCpp directly into
// `state.llama`. We keep an `Arc::clone` to observe `is_disposed()`
// after `close()` consumes `state`.
let llama = Arc::new(LlamaCpp::new(LlamaCppConfig {
ci_mode: true,
..LlamaCppConfig::default()
}));
let observer = Arc::clone(&llama);
let mut state = IndexState::new(None);
state.llama = Some(llama);
assert!(!observer.is_disposed());
state.close().await;
assert!(observer.is_disposed());
}
#[test]
fn set_index_name_switches_and_resets_lazy_handles() {
let mut state = IndexState::new(Some("index"));
assert_eq!(state.index_name(), "index");
// Pretend a local-config override was picked up.
state.db_path_override = Some(PathBuf::from("/tmp/local.sqlite"));
state.config_path_override = Some(PathBuf::from("/tmp/local.yml"));
state.set_index_name("release-notes");
assert_eq!(state.index_name(), "release-notes");
// Overrides cleared so the new index resolves from its name.
assert!(state.db_path_override.is_none());
assert!(state.config_path_override.is_none());
// Lazy handles dropped so the next access reopens the new index.
assert!(state.store.is_none());
assert!(state.config.is_none());
}
#[test]
fn set_index_name_sanitizes_path_like_names() {
let mut state = IndexState::new(None);
state.set_index_name("a/b");
assert!(!state.index_name().contains('/'));
}
}