use std::path::PathBuf;
use std::sync::Arc;
use ikigai_core::EndpointSpace;
use ikigai_sparql::Store;
use crate::config;
const RESERVED_ROOTS: [&str; 5] = ["status", "log", "branch", "list", "pr"];
pub(crate) struct Browse {
pub(crate) space: EndpointSpace,
pub(crate) store: Arc<Store>,
}
pub(crate) fn setup() -> Option<Browse> {
let roots = roots();
if roots.is_empty() {
return None;
}
let store_path = scoped("browse.store")
.map(|p| expand_home(&p))
.unwrap_or_else(|| {
let home = std::env::var_os("HOME").map_or_else(|| PathBuf::from("."), PathBuf::from);
home.join(".ikigai").join("browse-store")
});
let store = Arc::new(Store::open(&store_path).unwrap_or_else(|e| {
panic!(
"ikigai: browse.store `{}` cannot open: {e} — refusing to run with an \
in-memory archive (explanations and annotations would be silently lost). \
ONE process holds the store at a time; if another ikigai holds this lock, \
the fix is topology, not retry: scope the family to the SERVING instance \
(`serve.browse.root = …`) and point this process at it instead — \
mount = \"prefer urn:repo:=<serve socket>\" and \
mount = \"prefer urn:annotation:=<serve socket>\" in the config home. \
Otherwise fix the path/permissions.",
store_path.display()
)
}));
ikigai_sparql::load_vocabulary(&store)
.unwrap_or_else(|e| panic!("ikigai: loading the vocabulary into browse.store: {e:?}"));
let mut explain = ikigai_browse::ExplainConfig::new(Arc::clone(&store))
.file_provider(provider_iri(
&scoped("browse.file_model").unwrap_or_else(|| "coder".to_string()),
))
.dir_provider(provider_iri(
&scoped("browse.dir_model").unwrap_or_else(|| "ask".to_string()),
));
if let Some(tokens) = ceiling("browse.file_max_tokens") {
explain = explain.file_max_tokens(tokens);
}
if let Some(tokens) = ceiling("browse.dir_max_tokens") {
explain = explain.dir_max_tokens(tokens);
}
Some(Browse {
space: ikigai_browse::space_with_explain(roots, explain),
store,
})
}
fn roots() -> Vec<(String, PathBuf)> {
root_lines(
config::all(&format!("{}.browse.root", crate::instance_name())),
config::all("browse.root"),
&config::scoping_instances("browse.root"),
)
.into_iter()
.map(|line| {
let dir = expand_home(&line);
assert!(
dir.is_dir(),
"ikigai: browse.root `{line}` is not a directory — fix the config \
(a root that resolves against nothing would answer every request \
with an error)"
);
let name = dir
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
assert!(
!RESERVED_ROOTS.contains(&name.as_str()),
"ikigai: browse.root `{line}`: the name `{name}` is reserved — \
ikigai-repo binds urn:repo:{name} — rename the directory or \
browse it under a symlinked name"
);
(name, dir)
})
.collect()
}
fn root_lines(scoped: Vec<String>, unscoped: Vec<String>, scoping: &[String]) -> Vec<String> {
if scoping.is_empty() {
return unscoped;
}
assert!(
unscoped.is_empty(),
"ikigai: browse.root is scoped to {} but the config also has unscoped \
`browse.root` lines — every process honours an unscoped line, so this would \
put a second process on the store's exclusive lock. Scope ALL of them \
(`<instance>.browse.root`), designate ONE serving instance, and point every \
other process at it: mount = \"prefer urn:repo:=<serve socket>\" + \
mount = \"prefer urn:annotation:=<serve socket>\".",
scoping
.iter()
.map(|i| format!("`{i}.browse.root`"))
.collect::<Vec<_>>()
.join(", ")
);
scoped
}
fn scoped(key: &str) -> Option<String> {
config::get(&format!("{}.{key}", crate::instance_name())).or_else(|| config::get(key))
}
fn provider_iri(value: &str) -> String {
if value.starts_with("urn:") {
value.to_string()
} else if value == "ask" {
"urn:llm:ask".to_string()
} else {
format!("urn:llm:{value}:ask")
}
}
fn ceiling(key: &str) -> Option<u32> {
scoped(key).map(|v| {
v.parse()
.unwrap_or_else(|_| panic!("ikigai: {key} `{v}` is not a number — fix the config"))
})
}
fn expand_home(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home).join(rest);
}
}
PathBuf::from(path)
}
#[cfg(test)]
mod tests {
use super::{provider_iri, root_lines};
#[test]
fn provider_ids_become_iris() {
assert_eq!(provider_iri("coder"), "urn:llm:coder:ask");
assert_eq!(provider_iri("ask"), "urn:llm:ask");
assert_eq!(provider_iri("urn:llm:mlx:ask"), "urn:llm:mlx:ask");
}
#[test]
fn unscoped_roots_govern_when_nobody_scopes() {
assert_eq!(
root_lines(vec![], vec!["~/a".into(), "~/b".into()], &[]),
vec!["~/a".to_string(), "~/b".to_string()]
);
}
#[test]
fn scoped_roots_govern_only_their_instance() {
let scoping = ["serve".to_string()];
assert_eq!(
root_lines(vec!["~/a".into()], vec![], &scoping),
vec!["~/a".to_string()]
);
assert!(root_lines(vec![], vec![], &scoping).is_empty());
}
#[test]
#[should_panic(expected = "unscoped")]
fn mixing_scoped_and_unscoped_roots_is_refused() {
root_lines(
vec!["~/a".into()],
vec!["~/b".into()],
&["serve".to_string()],
);
}
}