use std::path::PathBuf;
pub const USAGE: &str = "\
ikigai-dev [socket] [flags]
socket where to serve (default ~/.ikigai/dev.sock; also `--socket`)
--config <file> read this file instead of ~/.config/ikigai/dev.toml
--socket <path> where to serve (overrides the file and the positional form)
--root <dir> browse root (repeatable; overrides ALL browse.root lines)
--store <path> explanation/annotation archive (default ~/.ikigai/browse-store)
--file-model <id> explain file grain: an id (urn:llm:{id}:ask), `ask`, or a full IRI
--dir-model <id> explain dir rollup, same spellings
--file-max-tokens <n> explain ceilings (defaults: 400 file, 600 dir)
--dir-max-tokens <n>
--review-model <id> review pass (urn:repo:{repo}:review:{path}), same spellings
--pr-model <id> pull-request explain, same spellings
--review-max-tokens <n> review/pr ceilings (defaults: 800 review, 600 pr)
--pr-max-tokens <n>
--review-model-label <s> operator overrides folded into version tags (unset, the
--pr-model-label <s> true model id resolves at derive time)
--help
Config file grammar (flags override it):
socket = \"~/.ikigai/dev.sock\"
browse.root = \"~/git-personal/ikigai-core\" # repeatable; enables the browse family
browse.store = \"~/.ikigai/browse-store\"
browse.file_model = \"coder\"
browse.dir_model = \"ask\"
browse.file_max_tokens = 400
browse.dir_max_tokens = 600
browse.review_model = \"coder\"
browse.review_max_tokens = 800
browse.pr_model = \"coder\"
browse.pr_max_tokens = 600
browse.review_model_label = \"qwen3:30b\"
browse.pr_model_label = \"qwen3:30b\"
";
const RESERVED_ROOTS: [&str; 5] = ["status", "log", "branch", "list", "pr"];
pub struct Settings {
pub socket: PathBuf,
pub browse: Option<BrowseSettings>,
}
pub struct BrowseSettings {
pub roots: Vec<(String, PathBuf)>,
pub store: PathBuf,
pub file_model: String,
pub dir_model: String,
pub review_model: String,
pub pr_model: String,
pub file_max_tokens: Option<u32>,
pub dir_max_tokens: Option<u32>,
pub review_max_tokens: Option<u32>,
pub pr_max_tokens: Option<u32>,
pub review_model_label: Option<String>,
pub pr_model_label: Option<String>,
}
pub fn settings() -> Settings {
let flags = Flags::parse(std::env::args().skip(1));
if flags.help {
print!("{USAGE}");
std::process::exit(0);
}
let text = match &flags.config {
Some(path) => std::fs::read_to_string(path).unwrap_or_else(|e| {
panic!("ikigai-dev: --config {path}: {e} — fix the path (or drop the flag)")
}),
None => std::fs::read_to_string(config_dir().join("dev.toml")).unwrap_or_default(),
};
merge(&flags, &text)
}
pub fn config_dir() -> PathBuf {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
let home = std::env::var_os("HOME").map_or_else(|| PathBuf::from("."), PathBuf::from);
home.join(".config")
});
base.join("ikigai")
}
pub 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)
}
#[derive(Default)]
struct Flags {
help: bool,
config: Option<String>,
socket: Option<String>,
roots: Vec<String>,
store: Option<String>,
file_model: Option<String>,
dir_model: Option<String>,
review_model: Option<String>,
pr_model: Option<String>,
file_max_tokens: Option<String>,
dir_max_tokens: Option<String>,
review_max_tokens: Option<String>,
pr_max_tokens: Option<String>,
review_model_label: Option<String>,
pr_model_label: Option<String>,
}
impl Flags {
fn parse(args: impl Iterator<Item = String>) -> Flags {
let mut flags = Flags::default();
let mut args = args;
let next_value = |args: &mut dyn Iterator<Item = String>, flag: &str| {
args.next()
.unwrap_or_else(|| panic!("ikigai-dev: {flag} needs a value\n\n{USAGE}"))
};
while let Some(arg) = args.next() {
match arg.as_str() {
"--help" | "-h" => flags.help = true,
"--config" => flags.config = Some(next_value(&mut args, "--config")),
"--socket" => flags.socket = Some(next_value(&mut args, "--socket")),
"--root" => flags.roots.push(next_value(&mut args, "--root")),
"--store" => flags.store = Some(next_value(&mut args, "--store")),
"--file-model" => flags.file_model = Some(next_value(&mut args, "--file-model")),
"--dir-model" => flags.dir_model = Some(next_value(&mut args, "--dir-model")),
"--file-max-tokens" => {
flags.file_max_tokens = Some(next_value(&mut args, "--file-max-tokens"));
}
"--dir-max-tokens" => {
flags.dir_max_tokens = Some(next_value(&mut args, "--dir-max-tokens"));
}
"--review-model" => {
flags.review_model = Some(next_value(&mut args, "--review-model"));
}
"--pr-model" => flags.pr_model = Some(next_value(&mut args, "--pr-model")),
"--review-max-tokens" => {
flags.review_max_tokens = Some(next_value(&mut args, "--review-max-tokens"));
}
"--pr-max-tokens" => {
flags.pr_max_tokens = Some(next_value(&mut args, "--pr-max-tokens"));
}
"--review-model-label" => {
flags.review_model_label = Some(next_value(&mut args, "--review-model-label"));
}
"--pr-model-label" => {
flags.pr_model_label = Some(next_value(&mut args, "--pr-model-label"));
}
other if other.starts_with('-') => {
panic!("ikigai-dev: unknown flag {other}\n\n{USAGE}")
}
other => {
assert!(
flags.socket.is_none(),
"ikigai-dev: unexpected argument {other} (socket already given)\n\n{USAGE}"
);
flags.socket = Some(other.to_string());
}
}
}
flags
}
fn wants_browse(&self) -> bool {
!self.roots.is_empty()
|| self.store.is_some()
|| self.file_model.is_some()
|| self.dir_model.is_some()
|| self.review_model.is_some()
|| self.pr_model.is_some()
|| self.file_max_tokens.is_some()
|| self.dir_max_tokens.is_some()
|| self.review_max_tokens.is_some()
|| self.pr_max_tokens.is_some()
|| self.review_model_label.is_some()
|| self.pr_model_label.is_some()
}
}
fn merge(flags: &Flags, text: &str) -> Settings {
let socket = flags
.socket
.clone()
.or_else(|| value_for(text, "socket"))
.unwrap_or_else(|| "~/.ikigai/dev.sock".to_string());
let root_lines = if flags.roots.is_empty() {
values_for(text, "browse.root")
} else {
flags.roots.clone()
};
let browse = if root_lines.is_empty() {
let dangling: Vec<&str> = browse_keys(text);
assert!(
dangling.is_empty() && !flags.wants_browse(),
"ikigai-dev: browse settings ({}) but no browse.root / --root — \
a server that started anyway would look configured while serving no \
browse family at all. Add at least one root, or remove the settings.",
if dangling.is_empty() {
"flags".to_string()
} else {
dangling.join(", ")
}
);
None
} else {
Some(BrowseSettings {
roots: named_roots(&root_lines),
store: expand_home(
&flags
.store
.clone()
.or_else(|| value_for(text, "browse.store"))
.unwrap_or_else(|| "~/.ikigai/browse-store".to_string()),
),
file_model: provider_iri(
&flags
.file_model
.clone()
.or_else(|| value_for(text, "browse.file_model"))
.unwrap_or_else(|| "coder".to_string()),
),
dir_model: provider_iri(
&flags
.dir_model
.clone()
.or_else(|| value_for(text, "browse.dir_model"))
.unwrap_or_else(|| "ask".to_string()),
),
review_model: provider_iri(
&flags
.review_model
.clone()
.or_else(|| value_for(text, "browse.review_model"))
.unwrap_or_else(|| "coder".to_string()),
),
pr_model: provider_iri(
&flags
.pr_model
.clone()
.or_else(|| value_for(text, "browse.pr_model"))
.unwrap_or_else(|| "coder".to_string()),
),
file_max_tokens: ceiling(
"browse.file_max_tokens",
flags
.file_max_tokens
.clone()
.or_else(|| value_for(text, "browse.file_max_tokens")),
),
dir_max_tokens: ceiling(
"browse.dir_max_tokens",
flags
.dir_max_tokens
.clone()
.or_else(|| value_for(text, "browse.dir_max_tokens")),
),
review_max_tokens: ceiling(
"browse.review_max_tokens",
flags
.review_max_tokens
.clone()
.or_else(|| value_for(text, "browse.review_max_tokens")),
),
pr_max_tokens: ceiling(
"browse.pr_max_tokens",
flags
.pr_max_tokens
.clone()
.or_else(|| value_for(text, "browse.pr_max_tokens")),
),
review_model_label: flags
.review_model_label
.clone()
.or_else(|| value_for(text, "browse.review_model_label")),
pr_model_label: flags
.pr_model_label
.clone()
.or_else(|| value_for(text, "browse.pr_model_label")),
})
};
Settings {
socket: expand_home(&socket),
browse,
}
}
fn browse_keys(text: &str) -> Vec<&str> {
let mut keys = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((name, _)) = line.split_once('=') {
let name = name.trim();
if name.starts_with("browse.") && !keys.contains(&name) {
keys.push(name);
}
}
}
keys
}
fn named_roots(lines: &[String]) -> Vec<(String, PathBuf)> {
lines
.iter()
.map(|line| {
let dir = expand_home(line);
assert!(
dir.is_dir(),
"ikigai-dev: 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-dev: 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 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, value: Option<String>) -> Option<u32> {
value.map(|v| {
v.parse()
.unwrap_or_else(|_| panic!("ikigai-dev: {key} `{v}` is not a number — fix the config"))
})
}
fn value_for(text: &str, key: &str) -> Option<String> {
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((name, value)) = line.split_once('=') {
if name.trim() == key {
return Some(unquote(value));
}
}
}
None
}
fn values_for(text: &str, key: &str) -> Vec<String> {
text.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.filter_map(|line| line.split_once('='))
.filter(|(name, _)| name.trim() == key)
.map(|(_, value)| unquote(value))
.collect()
}
fn unquote(value: &str) -> String {
value.trim().trim_matches(['"', '\'']).trim().to_string()
}
#[cfg(test)]
mod tests {
use super::{browse_keys, merge, provider_iri, values_for, Flags};
#[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 repeated_keys_all_come_back() {
let text = "# roots\nbrowse.root = \"~/a\"\nother = 1\nbrowse.root = ~/b\n";
assert_eq!(
values_for(text, "browse.root"),
vec!["~/a".to_string(), "~/b".to_string()]
);
assert!(values_for(text, "absent").is_empty());
}
#[test]
fn empty_config_is_the_bare_server() {
let settings = merge(&Flags::default(), "");
assert!(settings.socket.ends_with(".ikigai/dev.sock"));
assert!(settings.browse.is_none());
}
#[test]
#[should_panic(expected = "no browse.root")]
fn tuning_without_a_root_is_refused() {
merge(&Flags::default(), "browse.file_model = \"coder\"\n");
}
#[test]
#[should_panic(expected = "no browse.root")]
fn flag_tuning_without_a_root_is_refused() {
let flags = Flags {
store: Some("/tmp/x".to_string()),
..Flags::default()
};
merge(&flags, "");
}
#[test]
#[should_panic(expected = "not a number")]
fn garbage_ceiling_is_refused() {
let dir = std::env::temp_dir();
let flags = Flags {
roots: vec![dir.to_string_lossy().into_owned()],
file_max_tokens: Some("many".to_string()),
..Flags::default()
};
merge(&flags, "");
}
#[test]
fn flags_override_the_file() {
let dir = std::env::temp_dir().join("ikigai-dev-test-root");
std::fs::create_dir_all(&dir).unwrap();
let flags = Flags {
socket: Some("/tmp/other.sock".to_string()),
roots: vec![dir.to_string_lossy().into_owned()],
file_model: Some("mlx".to_string()),
..Flags::default()
};
let text = "socket = \"/tmp/file.sock\"\nbrowse.root = \"/nonexistent/ignored\"\n";
let settings = merge(&flags, text);
assert_eq!(settings.socket.to_string_lossy(), "/tmp/other.sock");
let browse = settings.browse.expect("browse configured");
assert_eq!(browse.roots.len(), 1);
assert_eq!(browse.roots[0].0, "ikigai-dev-test-root");
assert_eq!(browse.file_model, "urn:llm:mlx:ask");
assert_eq!(browse.dir_model, "urn:llm:ask");
assert!(browse.store.ends_with(".ikigai/browse-store"));
assert!(browse.file_max_tokens.is_none());
assert_eq!(browse.review_model, "urn:llm:coder:ask");
assert_eq!(browse.pr_model, "urn:llm:coder:ask");
assert!(browse.review_max_tokens.is_none());
assert!(browse.pr_max_tokens.is_none());
assert!(browse.review_model_label.is_none());
assert!(browse.pr_model_label.is_none());
}
#[test]
fn review_and_pr_knobs_flow_through() {
let dir = std::env::temp_dir().join("ikigai-dev-test-root");
std::fs::create_dir_all(&dir).unwrap();
let flags = Flags {
roots: vec![dir.to_string_lossy().into_owned()],
..Flags::default()
};
let text = "browse.review_model = \"mlx\"\n\
browse.review_model_label = \"qwen3-coder:30b\"\n\
browse.review_max_tokens = 1600\n\
browse.pr_model = \"urn:llm:big:ask\"\n\
browse.pr_max_tokens = 900\n";
let browse = merge(&flags, text).browse.expect("browse configured");
assert_eq!(browse.review_model, "urn:llm:mlx:ask");
assert_eq!(
browse.review_model_label.as_deref(),
Some("qwen3-coder:30b")
);
assert_eq!(browse.review_max_tokens, Some(1600));
assert_eq!(browse.pr_model, "urn:llm:big:ask");
assert!(browse.pr_model_label.is_none());
assert_eq!(browse.pr_max_tokens, Some(900));
}
#[test]
fn review_and_pr_flags_override_the_file() {
let dir = std::env::temp_dir().join("ikigai-dev-test-root");
std::fs::create_dir_all(&dir).unwrap();
let flags = Flags {
roots: vec![dir.to_string_lossy().into_owned()],
review_max_tokens: Some("2000".to_string()),
pr_model_label: Some("pinned".to_string()),
..Flags::default()
};
let text = "browse.review_max_tokens = 800\nbrowse.pr_model_label = \"file\"\n";
let browse = merge(&flags, text).browse.expect("browse configured");
assert_eq!(browse.review_max_tokens, Some(2000));
assert_eq!(browse.pr_model_label.as_deref(), Some("pinned"));
}
#[test]
#[should_panic(expected = "not a number")]
fn garbage_review_ceiling_is_refused() {
let dir = std::env::temp_dir();
let flags = Flags {
roots: vec![dir.to_string_lossy().into_owned()],
review_max_tokens: Some("plenty".to_string()),
..Flags::default()
};
merge(&flags, "");
}
#[test]
#[should_panic(expected = "no browse.root")]
fn review_tuning_without_a_root_is_refused() {
let flags = Flags {
pr_max_tokens: Some("900".to_string()),
..Flags::default()
};
merge(&flags, "");
}
#[test]
#[should_panic(expected = "reserved")]
fn reserved_root_names_are_refused() {
let dir = std::env::temp_dir().join("status");
std::fs::create_dir_all(&dir).unwrap();
let flags = Flags {
roots: vec![dir.to_string_lossy().into_owned()],
..Flags::default()
};
merge(&flags, "");
}
#[test]
fn browse_keys_surface_for_the_error() {
let text = "browse.store = \"/x\"\n# browse.root = commented\nbrowse.dir_model = a\n";
assert_eq!(browse_keys(text), vec!["browse.store", "browse.dir_model"]);
}
}