use crate::daemon::{self, State, Store};
use crate::filter::{Filter, LANGUAGES};
use crate::http::{Handler, Request, Response};
use crate::{chunk, embed, home, store};
use fastembed::TextEmbedding;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::Ordering;
const FILE_PAGE: usize = 500;
pub fn handler(state: Arc<State>) -> Handler {
Arc::new(move |request| route(&state, request))
}
fn route(state: &Arc<State>, request: &Request) -> Response {
let path = request.path.as_str();
let get = request.method == "GET";
let post = request.method == "POST";
if get && let Some((kind, bytes)) = crate::portal::asset(path) {
return Response::asset(kind, bytes);
}
match (get, post, path) {
(true, _, "/") => crate::portal::page(),
(true, _, "/api/stores") => stores(state),
(true, _, "/api/files") => files(state, request),
(true, _, "/api/search") => search(state, request),
(true, _, "/api/models") => models(),
(true, _, "/api/languages") => languages(),
(true, _, "/api/dirs") => dirs(request),
(true, _, "/api/privacy") => privacy(state),
(true, _, "/api/about") => about(state),
(true, _, "/api/agents") => agents(state),
(true, _, "/api/symbol") => symbol(state, request),
(true, _, "/api/neighbors") => neighbors(state, request),
(true, _, "/api/path") => shortest_path(state, request),
(true, _, "/api/impact") => impact(state, request),
(true, _, "/api/graph") => graph(state, request),
(true, _, "/api/ledger") => ledger(state),
(true, _, "/api/setup") => setup(),
(_, true, "/api/index") => index(state, request),
(_, true, "/api/add") => add(state, request),
(_, true, "/api/forget") => forget(state, request),
(_, true, "/api/adopt") => adopt(state, request),
(_, true, "/api/rotate") => rotate(state),
(_, true, "/api/mcp") => mcp(state, request),
(_, true, "/api/upgrade") => upgrade(request),
(_, _, p) if p.starts_with("/api/") => Response::error(405, "wrong method for this route"),
_ => Response::error(404, "no such route"),
}
}
fn stores(state: &Arc<State>) -> Response {
if let Err(e) = state.open_fleet() {
return Response::error(500, &e.to_string());
}
let mut fleet = state.fleet.lock().expect("the fleet lock");
let mut out = Vec::new();
for handle in state.stores() {
let stats = fleet
.as_mut()
.and_then(|f| {
f.each()
.find(|(_, s)| s.dir() == handle.dir)
.map(|(_, s)| s)
})
.map(|s| {
(
s.stats(),
s.model().to_string(),
s.dim(),
s.len(),
s.shards(),
)
});
let (files, chunks, bytes, model, dim, vectors, shards) = match stats {
Some((Ok((f, c, b)), model, dim, len, shards)) => (f, c, b, model, dim, len, shards),
_ => (0, 0, 0, String::new(), 0, 0, None),
};
out.push(json!({
"name": handle.name,
"dir": handle.dir.display().to_string(),
"roots": handle.roots.iter().map(|r| json!({
"path": r.display().to_string(),
"present": r.exists(),
})).collect::<Vec<_>>(),
"files": files,
"chunks": chunks,
"bytes": bytes,
"model": model,
"dim": dim,
"vectors": vectors,
"shards": shards.map(|(n, max)| json!({ "count": n, "resident": max })),
"watching": handle.watching.load(Ordering::Relaxed),
"queue": handle.queue_depth(),
"last_write": handle.last_write.load(Ordering::Relaxed),
"events": handle.events(),
}));
}
Response::json(&json!({ "stores": out }))
}
fn files(state: &Arc<State>, request: &Request) -> Response {
let filter = match filter_of(request) {
Ok(f) => f,
Err(e) => return Response::error(400, &e),
};
let only = request.query_all("store");
if let Err(e) = state.open_fleet() {
return Response::error(500, &e.to_string());
}
let mut fleet = state.fleet.lock().expect("the fleet lock");
let Some(fleet) = fleet.as_mut() else {
return Response::json(&json!({ "files": [], "total": 0 }));
};
let mut rows = Vec::new();
let mut total = 0usize;
for (label, opened) in fleet.each() {
if !only.is_empty() && !only.iter().any(|n| n == label) {
continue;
}
let listed = match store::file_rows(opened.db(), filter.groups()) {
Ok(r) => r,
Err(e) => return Response::error(500, &e.to_string()),
};
total += listed.len();
for (path, bytes, chunks, lines) in listed {
if rows.len() >= FILE_PAGE {
continue;
}
let as_path = Path::new(&path);
rows.push(json!({
"store": label,
"path": path,
"ext": as_path.extension().and_then(|e| e.to_str()).unwrap_or(""),
"lang": language_of(as_path),
"reader": chunk::reader_of(as_path),
"bytes": bytes,
"chunks": chunks,
"lines": lines,
}));
}
}
Response::json(&json!({ "files": rows, "total": total, "page": FILE_PAGE }))
}
fn search(state: &Arc<State>, request: &Request) -> Response {
let Some(query) = request.query("query").filter(|q| !q.trim().is_empty()) else {
return Response::error(400, "missing query");
};
let k = request
.query("k")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(8)
.clamp(1, 100);
let filter = match filter_of(request) {
Ok(f) => f,
Err(e) => return Response::error(400, &e),
};
let only = request.query_all("store");
if let Err(e) = state.open_fleet() {
return Response::error(500, &e.to_string());
}
let mut fleet = state.fleet.lock().expect("the fleet lock");
let Some(fleet) = fleet.as_mut() else {
return Response::json(&json!({ "hits": [], "selected": 0 }));
};
let selected = if filter.is_empty() {
None
} else {
match fleet.matching_files(&filter) {
Ok(n) => Some(n),
Err(e) => return Response::error(500, &e.to_string()),
}
};
if selected == Some(0) {
return Response::json(&json!({ "hits": [], "selected": 0, "chunks": fleet.chunks() }));
}
let started = std::time::Instant::now();
let only = (!only.is_empty()).then_some(only);
let hits = match fleet.search_in(only.as_deref(), query, k, &filter) {
Ok(h) => h,
Err(e) => return Response::error(500, &e.to_string()),
};
let elapsed = started.elapsed();
let single = fleet.len() == 1;
let label = fleet.labels().first().map(|s| s.to_string());
let out: Vec<Value> = hits
.iter()
.map(|h| {
json!({
"score": h.score,
"path": h.path,
"start_line": h.start_line,
"end_line": h.end_line,
"text": h.text,
"store": h.store.clone().or_else(|| single.then(|| label.clone()).flatten()),
"lists": h.lists,
})
})
.collect();
if state.ledger {
record(fleet, "portal", query, &hits, elapsed);
}
Response::json(&json!({
"hits": out,
"selected": selected,
"chunks": fleet.chunks(),
"micros": elapsed.as_micros() as u64,
}))
}
fn record(
fleet: &crate::fleet::Fleet,
client: &str,
query: &str,
hits: &[crate::Hit],
elapsed: std::time::Duration,
) {
const CHARS_PER_TOKEN: i64 = 4;
let excerpt: i64 = hits.iter().map(|h| h.text.len() as i64).sum::<i64>() / CHARS_PER_TOKEN;
for (label, store) in fleet.each() {
let mine: Vec<&crate::Hit> = hits
.iter()
.filter(|h| h.store.as_deref().is_none_or(|s| s == label))
.collect();
if mine.is_empty() {
continue;
}
let paths: std::collections::BTreeSet<&str> =
mine.iter().map(|h| h.path.as_str()).collect();
let whole: i64 = paths
.iter()
.filter_map(|p| std::fs::metadata(p).ok())
.map(|m| m.len() as i64 / CHARS_PER_TOKEN)
.sum();
let _ = store::record_retrieval(
store.db(),
client,
query,
mine.len() as i64,
elapsed.as_micros() as i64,
excerpt,
whole,
);
}
}
fn ledger(state: &Arc<State>) -> Response {
let empty = json!({
"recording": state.ledger,
"queries": 0,
"clients": 0,
"excerpt_tokens": 0,
"whole_file_tokens": 0,
"ratio": null,
"intact": true,
});
let recording = state.ledger;
with_fleet(state, empty, move |fleet| {
let (mut queries, mut clients, mut excerpt, mut whole) = (0, 0, 0, 0);
let mut intact = true;
for (_, store) in fleet.each() {
let (q, c, e, w) = store::ledger_totals(store.db())?;
queries += q;
clients = clients.max(c);
excerpt += e;
whole += w;
intact = intact && store::ledger_break(store.db())?.is_none();
}
let ratio = (excerpt > 0).then(|| whole as f64 / excerpt as f64);
Ok(json!({
"recording": recording,
"queries": queries,
"clients": clients,
"excerpt_tokens": excerpt,
"whole_file_tokens": whole,
"ratio": ratio,
"intact": intact,
}))
})
}
fn models() -> Response {
let mut out = vec![json!({
"name": embed::GRANITE_NAME,
"dim": 384,
"description": "default. IBM Granite R2 small, int8, English",
})];
for info in TextEmbedding::list_supported_models() {
out.push(json!({
"name": info.model.to_string(),
"dim": info.dim,
"description": info.description,
}));
}
Response::json(&json!({ "models": out }))
}
fn languages() -> Response {
let out: Vec<Value> = LANGUAGES
.iter()
.map(|entry| {
json!({
"name": entry.name,
"extensions": entry.extensions,
"filenames": entry.filenames,
})
})
.collect();
Response::json(&json!({ "languages": out }))
}
fn dirs(request: &Request) -> Response {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/"));
let home = crate::canonical(&home);
let asked = request
.query("path")
.filter(|p| !p.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| home.clone());
let resolved = crate::canonical(&asked);
if !resolved.starts_with(&home) {
return Response::error(400, "outside the home directory");
}
let mut entries = Vec::new();
let listing = match std::fs::read_dir(&resolved) {
Ok(l) => l,
Err(e) => return Response::error(400, &e.to_string()),
};
for entry in listing.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
continue;
}
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
entries.push(json!({
"name": name,
"path": entry.path().display().to_string(),
"dir": is_dir,
}));
}
entries.sort_by(|a, b| {
let (ad, bd) = (a["dir"].as_bool(), b["dir"].as_bool());
bd.cmp(&ad).then_with(|| {
a["name"]
.as_str()
.unwrap_or("")
.cmp(b["name"].as_str().unwrap_or(""))
})
});
Response::json(&json!({
"path": resolved.display().to_string(),
"home": home.display().to_string(),
"parent": resolved
.parent()
.filter(|p| p.starts_with(&home))
.map(|p| p.display().to_string()),
"entries": entries,
}))
}
fn privacy(state: &Arc<State>) -> Response {
let cache = crate::model_cache_dir();
Response::json(&json!({
"bind": format!("127.0.0.1:{}", state.server.port()),
"bind_is_fixed": true,
"airgap": state.airgap,
"model_cache": cache.display().to_string(),
"model_cached": cache.exists()
&& std::fs::read_dir(&cache).map(|mut d| d.next().is_some()).unwrap_or(false),
"token_cookie": crate::http::TOKEN_COOKIE,
"host_allowed": ["localhost", "127.0.0.1", "::1"],
"csp": "default-src 'self'",
"cors": false,
"store_home": home::home().display().to_string(),
}))
}
fn about(state: &Arc<State>) -> Response {
Response::json(&json!({
"version": env!("CARGO_PKG_VERSION"),
"binary": std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_default(),
"port": state.server.port(),
"pid": std::process::id(),
"uptime": daemon::uptime(state),
"store_home": home::home().display().to_string(),
"model_cache": crate::model_cache_dir().display().to_string(),
"models": TextEmbedding::list_supported_models().len() + 1,
"languages": LANGUAGES.len(),
"graph_languages": crate::graph::LANGUAGES,
"edge_kinds": crate::graph::KINDS,
"stores": state.stores().len(),
}))
}
fn agents(state: &Arc<State>) -> Response {
Response::json(&json!({
"forwarding": state.proxy_count() > 0,
"connected": state.proxy_count(),
"tools": crate::mcp::tool_names(),
"revisions": crate::mcp::SUPPORTED,
"clients": crate::clients::clients(),
"install": {
"sh": crate::setup::INSTALL_SH,
"ps1": crate::setup::INSTALL_PS1,
},
}))
}
fn setup() -> Response {
Response::json(&json!(crate::setup::status()))
}
fn with_fleet(
state: &Arc<State>,
empty: Value,
read: impl FnOnce(&crate::fleet::Fleet) -> anyhow::Result<Value>,
) -> Response {
if let Err(e) = state.open_fleet() {
return Response::error(500, &e.to_string());
}
let fleet = state.fleet.lock().expect("the fleet lock");
let Some(fleet) = fleet.as_ref() else {
return Response::json(&empty);
};
match read(fleet) {
Ok(value) => Response::json(&value),
Err(e) => Response::error(500, &e.to_string()),
}
}
fn symbol(state: &Arc<State>, request: &Request) -> Response {
let Some(name) = request.query("name").filter(|n| !n.trim().is_empty()) else {
return Response::error(400, "missing name");
};
let name = name.to_string();
let k = request
.query("k")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(20)
.clamp(1, 200);
let only = request.query_all("store");
with_fleet(state, json!({ "symbols": [] }), move |fleet| {
let only = (!only.is_empty()).then_some(only);
let found = fleet.symbols_in(only.as_deref(), &name, k)?;
Ok(json!({ "symbols": found }))
})
}
fn neighbors(state: &Arc<State>, request: &Request) -> Response {
let Some(name) = request.query("name").filter(|n| !n.trim().is_empty()) else {
return Response::error(400, "missing name");
};
let name = name.to_string();
let kinds = request.query_all("kind");
if let Some(bad) = kinds
.iter()
.find(|k| !crate::graph::KINDS.contains(&k.as_str()))
{
return Response::error(400, &format!("unknown edge kind {bad:?}"));
}
let only = request.query_all("store");
let empty = json!({ "callers": [], "callees": [] });
with_fleet(state, empty, move |fleet| {
let only = (!only.is_empty()).then_some(only);
Ok(json!(fleet.neighbours_in(
only.as_deref(),
&name,
&kinds
)?))
})
}
fn shortest_path(state: &Arc<State>, request: &Request) -> Response {
let (Some(from), Some(to)) = (request.query("from"), request.query("to")) else {
return Response::error(400, "missing from or to");
};
let (from, to) = (from.to_string(), to.to_string());
let depth = request
.query("depth")
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(6)
.clamp(1, 20);
let only = request.query_all("store");
with_fleet(state, json!({ "path": null }), move |fleet| {
let only = (!only.is_empty()).then_some(only);
Ok(json!({ "path": fleet.path_in(only.as_deref(), &from, &to, depth)? }))
})
}
fn impact(state: &Arc<State>, request: &Request) -> Response {
let Some(name) = request.query("name").filter(|n| !n.trim().is_empty()) else {
return Response::error(400, "missing name");
};
let name = name.to_string();
let depth = request
.query("depth")
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(crate::graph::DEFAULT_DEPTH)
.clamp(1, 20);
let only = request.query_all("store");
let empty = json!({ "reached": [], "depth": depth, "truncated": false });
with_fleet(state, empty, move |fleet| {
let only = (!only.is_empty()).then_some(only);
let reached = fleet.impact_in(only.as_deref(), &name, depth)?;
let truncated = reached.len() >= crate::graph::MAX_NODES;
Ok(json!({ "reached": reached, "depth": depth, "truncated": truncated }))
})
}
fn graph(state: &Arc<State>, request: &Request) -> Response {
let focus = request.query("name").map(str::to_string);
let prefix = request.query("path").map(str::to_string);
let limit = request
.query("limit")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(70)
.clamp(1, crate::graph::MAX_NODES);
let only = request.query_all("store");
let empty = json!({ "nodes": [], "edges": [], "total": 0, "shown": 0 });
with_fleet(state, empty, move |fleet| {
let only = (!only.is_empty()).then_some(only);
let chosen: Vec<(&str, &crate::Semlith)> = match &only {
Some(names) => fleet
.each()
.filter(|(label, _)| names.iter().any(|n| n == label))
.collect(),
None => fleet.each().collect(),
};
let many = fleet.len() > 1;
crate::graph::scoped(&chosen, focus.as_deref(), prefix.as_deref(), limit, many)
})
}
fn index(state: &Arc<State>, request: &Request) -> Response {
let body = match request.json() {
Ok(b) => b,
Err(e) => return Response::error(400, &e.to_string()),
};
let paths: Vec<PathBuf> = strings(&body, "path")
.into_iter()
.map(PathBuf::from)
.collect();
if paths.is_empty() {
return Response::error(400, "no path given");
}
let named = body.get("store").and_then(Value::as_str);
let store = match state.writable(named) {
Ok(s) => s,
Err(e) if named.is_none() && state.stores().is_empty() => {
match first_store(state, &paths[0]) {
Ok(store) => store,
Err(made) => return Response::error(409, &format!("{e}: {made:#}")),
}
}
Err(e) => return Response::error(409, &e.to_string()),
};
match state.index(&store, paths) {
Ok(progress) => stream(progress),
Err(e) => Response::error(409, &e.to_string()),
}
}
fn first_store(state: &Arc<State>, path: &Path) -> Result<Arc<Store>, anyhow::Error> {
let choice = home::resolve(&[], path, None)?;
let dir = choice.one()?;
{
let store = crate::Semlith::open(&dir, None)?;
let model = store.model().to_string();
home::record(&choice, std::slice::from_ref(&path.to_path_buf()), &model)?;
}
state.open_store(&dir)
}
fn add(state: &Arc<State>, request: &Request) -> Response {
let body = match request.json() {
Ok(b) => b,
Err(e) => return Response::error(400, &e.to_string()),
};
let Some(url) = body
.get("url")
.and_then(Value::as_str)
.filter(|u| !u.is_empty())
else {
return Response::error(400, "no url given");
};
let store = match state.writable(body.get("store").and_then(Value::as_str)) {
Ok(s) => s,
Err(e) => return Response::error(409, &e.to_string()),
};
let fetched = match crate::add::fetch(url, &store.dir) {
Ok(fetched) => fetched,
Err(e) => return Response::error(400, &format!("{e:#}")),
};
match state.index(&store, vec![fetched.path.clone()]) {
Ok(progress) => stream(progress),
Err(e) => Response::error(409, &e.to_string()),
}
}
fn upgrade(request: &Request) -> Response {
let body = match request.json() {
Ok(b) => b,
Err(e) => return Response::error(400, &e.to_string()),
};
match body.get("action").and_then(Value::as_str) {
Some("check") => match crate::upgrade::check() {
Ok(found) => Response::json(&json!(found)),
Err(e) => Response::error(502, &e.to_string()),
},
Some("apply") => match crate::upgrade::apply(
body.get("version")
.and_then(Value::as_str)
.map(String::from),
) {
Ok(()) => Response::json(&json!({
"installed": true,
"restart": "semlith start is still running the old binary — restart it",
})),
Err(e) => Response::error(409, &e.to_string()),
},
_ => Response::error(400, "action must be \"check\" or \"apply\""),
}
}
fn forget(state: &Arc<State>, request: &Request) -> Response {
let body = match request.json() {
Ok(b) => b,
Err(e) => return Response::error(400, &e.to_string()),
};
let Some(path) = body.get("path").and_then(Value::as_str) else {
return Response::error(400, "no path given");
};
let store = match state.writable(body.get("store").and_then(Value::as_str)) {
Ok(s) => s,
Err(e) => return Response::error(409, &e.to_string()),
};
let progress = match state.forget(&store, PathBuf::from(path)) {
Ok(p) => p,
Err(e) => return Response::error(409, &e.to_string()),
};
match progress.recv() {
Ok(value) => Response::json(&value),
Err(_) => Response::error(500, "the writer stopped before answering"),
}
}
fn adopt(state: &Arc<State>, request: &Request) -> Response {
let body = match request.json() {
Ok(b) => b,
Err(e) => return Response::error(400, &e.to_string()),
};
let Some(dir) = body.get("path").and_then(Value::as_str) else {
return Response::error(400, "no path given");
};
let dir = PathBuf::from(dir);
if state
.stores()
.iter()
.any(|s| s.dir == crate::canonical(&dir))
{
return Response::error(409, "this daemon is already using that store");
}
match home::adopt(&dir, None, None) {
Ok((name, target)) => Response::json(&json!({
"name": name,
"dir": target.display().to_string(),
"restart_required": true,
})),
Err(e) => Response::error(400, &e.to_string()),
}
}
fn mcp(state: &Arc<State>, request: &Request) -> Response {
if let Some(pid) = crate::proxy::proxy_pid(request.header("semlith-proxy")) {
state.saw_proxy(pid);
}
let body = match request.json() {
Ok(b) => b,
Err(e) => return Response::error(400, &e.to_string()),
};
if let Err(e) = state.open_mcp_fleet() {
return Response::error(409, &e.to_string());
}
let writer = daemon::Writer(Arc::clone(state));
let mut fleet = state.mcp_fleet.lock().expect("the mcp fleet lock");
let Some(fleet) = fleet.as_mut() else {
return Response::error(409, "this daemon has no store open");
};
match crate::mcp::answer(fleet, Some(&writer), &body) {
Some(value) => Response::json(&value),
None => Response::new(200, "application/json; charset=utf-8", Vec::new()),
}
}
fn rotate(state: &Arc<State>) -> Response {
let fresh = state.rotate();
Response::json(&json!({ "token": fresh, "url": daemon::url(state) })).with_token(&fresh)
}
fn stream(progress: std::sync::mpsc::Receiver<Value>) -> Response {
Response::stream(move |chunks| {
for event in progress {
let mut line = event.to_string();
line.push('\n');
chunks.send(&line)?;
}
Ok(())
})
}
fn filter_of(request: &Request) -> Result<Filter, String> {
Filter::new(
&request.query_all("path"),
&request.query_all("ext"),
&request.query_all("lang"),
)
.map_err(|e| e.to_string())
}
fn strings(value: &Value, key: &str) -> Vec<String> {
match value.get(key) {
Some(Value::String(s)) => vec![s.clone()],
Some(Value::Array(items)) => items
.iter()
.filter_map(|i| i.as_str().map(str::to_string))
.collect(),
_ => Vec::new(),
}
}
fn language_of(path: &Path) -> &'static str {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
LANGUAGES
.iter()
.find(|entry| {
(!ext.is_empty() && entry.extensions.contains(&ext.as_str()))
|| entry
.filenames
.iter()
.any(|pattern| matches(pattern, &name))
})
.map(|entry| entry.name)
.unwrap_or("")
}
fn matches(pattern: &str, name: &str) -> bool {
match pattern.strip_suffix('*') {
Some(prefix) => name.starts_with(prefix),
None => name == pattern,
}
}
#[allow(dead_code)]
fn named(state: &Arc<State>, name: &str) -> Option<Arc<Store>> {
state.store(name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_file_is_labelled_with_the_reader_that_would_parse_it() {
assert_eq!(chunk::reader_of(Path::new("a/b.docx")), "word");
assert_eq!(chunk::reader_of(Path::new("a/b.ipynb")), "notebook");
assert_eq!(chunk::reader_of(Path::new("a/b.rs")), "text");
assert_eq!(chunk::reader_of(Path::new("a/b.PDF")), "pdf");
}
#[test]
fn a_file_is_labelled_with_the_language_lang_would_select() {
assert_eq!(language_of(Path::new("src/lib.rs")), "rust");
assert_eq!(language_of(Path::new("a/b.MD")), "markdown");
assert_eq!(language_of(Path::new("a/b.unknown")), "");
assert_eq!(language_of(Path::new("LICENSE")), "");
}
#[test]
fn a_path_is_taken_as_a_string_or_an_array() {
assert_eq!(strings(&json!({ "path": "a" }), "path"), vec!["a"]);
assert_eq!(
strings(&json!({ "path": ["a", "b"] }), "path"),
vec!["a", "b"]
);
assert!(strings(&json!({}), "path").is_empty());
}
}