use anyhow::{Context, Result};
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Debug, Default, Deserialize)]
pub struct Config {
pub index_dir: Option<String>,
pub llm_model: Option<String>,
pub embed_model: Option<String>,
pub llm_base_url: Option<String>,
pub max_enrich: Option<usize>,
pub max_flows: Option<usize>,
pub format: Option<String>,
pub embed: Option<bool>,
pub enrich: Option<bool>,
pub flows: Option<bool>,
pub guide: Option<bool>,
pub scip: Option<String>,
pub signing_key: Option<String>,
pub allowed_signers: Option<String>,
pub cache_ttl_secs: Option<u64>,
pub aws_profile: Option<String>,
pub author_name: Option<String>,
pub record_names: Option<bool>,
pub auto_index: Option<bool>,
pub sso_auto_login: Option<bool>,
#[serde(default)]
pub repos: Vec<RepoEntry>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, serde::Serialize)]
pub struct RepoEntry {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
}
pub fn set_repos(list: &[RepoEntry]) -> Result<()> {
let path = config_path();
let text = std::fs::read_to_string(&path).unwrap_or_default();
let mut doc: toml_edit::DocumentMut = text.parse().context("parse config.toml")?;
let mut tables = toml_edit::ArrayOfTables::new();
for r in list {
let mut t = toml_edit::Table::new();
t["path"] = toml_edit::value(r.path.as_str());
if let Some(b) = &r.branch {
t["branch"] = toml_edit::value(b.as_str());
}
tables.push(t);
}
if list.is_empty() {
doc.remove("repos");
} else {
doc["repos"] = toml_edit::Item::ArrayOfTables(tables);
}
std::fs::create_dir_all(path.parent().context("config dir")?)?;
std::fs::write(&path, doc.to_string()).with_context(|| format!("write {}", path.display()))
}
impl Config {
pub fn load() -> Self {
let path = config_path();
let Ok(text) = std::fs::read_to_string(&path) else {
return Self::default();
};
match toml::from_str(&text) {
Ok(cfg) => cfg,
Err(e) => {
eprintln!("comindly: ignoring {}: {e}", path.display());
Self::default()
}
}
}
pub fn index_dir(&self, flag: Option<&str>) -> String {
if let Some(f) = flag {
return expand_tilde(f);
}
if let Some(e) = non_empty_env("COMINDLY_INDEX_DIR") {
return expand_tilde(&e);
}
if let Some(d) = &self.index_dir {
return expand_tilde(d);
}
default_index_dir()
}
pub fn graph_dir(&self, flag: Option<&str>) -> String {
format!("{}/_graph", self.index_dir(flag).trim_end_matches('/'))
}
pub fn fresh(&self) -> std::time::Duration {
std::time::Duration::from_secs(self.cache_ttl_secs.unwrap_or(crate::cache::DEFAULT_TTL))
}
pub fn llm_model(&self) -> String {
team(|t| t.llm_model.clone())
.or_else(|| non_empty_env("COMINDLY_LLM_MODEL"))
.or_else(|| self.llm_model.clone())
.unwrap_or_else(|| crate::llm::DEFAULT_MODEL.to_string())
}
pub fn embed_model(&self) -> String {
team(|t| t.embed_model.clone())
.or_else(|| non_empty_env("COMINDLY_EMBED_MODEL"))
.or_else(|| self.embed_model.clone())
.unwrap_or_else(|| crate::embed::DEFAULT_MODEL.to_string())
}
pub fn llm_base_url(&self) -> Option<String> {
team(|t| t.llm_base_url.clone())
.or_else(|| non_empty_env("COMINDLY_LLM_BASE_URL"))
.or_else(|| self.llm_base_url.clone())
}
pub fn max_enrich(&self) -> usize {
self.max_enrich.unwrap_or(usize::MAX)
}
pub fn max_flows(&self) -> usize {
self.max_flows.unwrap_or(usize::MAX)
}
pub fn format(&self) -> String {
non_empty_env("COMINDLY_FORMAT")
.or_else(|| self.format.clone())
.unwrap_or_else(|| "md".to_string())
}
pub fn embed(&self) -> bool {
self.embed.unwrap_or(true)
}
pub fn enrich(&self) -> bool {
team(|t| t.enrich).or(self.enrich).unwrap_or(true)
}
pub fn flows(&self) -> bool {
team(|t| t.flows).or(self.flows).unwrap_or(true)
}
pub fn guide(&self) -> bool {
team(|t| t.guide).or(self.guide).unwrap_or(true)
}
pub fn scip(&self) -> crate::scip::Mode {
non_empty_env("COMINDLY_SCIP")
.or_else(|| self.scip.clone())
.and_then(|s| crate::scip::Mode::parse(&s))
.unwrap_or(crate::scip::Mode::Auto)
}
}
pub fn config_path() -> PathBuf {
config_dir().join("config.toml")
}
pub fn config_dir() -> PathBuf {
config_home().join("comindly")
}
pub enum Setting {
Text(String),
Flag(bool),
Number(i64),
Remove,
}
pub fn update(changes: &[(&str, Setting)]) -> Result<()> {
let path = config_path();
let text = std::fs::read_to_string(&path).unwrap_or_default();
let mut doc: toml_edit::DocumentMut = text.parse().context("parse config.toml")?;
for (key, value) in changes {
match value {
Setting::Text(v) => doc[key] = toml_edit::value(v.as_str()),
Setting::Flag(v) => doc[key] = toml_edit::value(*v),
Setting::Number(v) => doc[key] = toml_edit::value(*v),
Setting::Remove => {
doc.remove(key);
}
}
}
std::fs::create_dir_all(path.parent().context("config dir")?)?;
std::fs::write(&path, doc.to_string()).with_context(|| format!("write {}", path.display()))
}
fn team<T>(pick: impl Fn(&crate::team::TeamSettings) -> Option<T>) -> Option<T> {
crate::team::current().and_then(|t| pick(&t))
}
pub fn ui_token() -> Result<String> {
let path = config_dir().join("ui-token");
if let Ok(t) = std::fs::read_to_string(&path) {
if !t.trim().is_empty() {
return Ok(t.trim().to_string());
}
}
std::fs::create_dir_all(path.parent().context("config dir")?)?;
let token = crate::web::new_token();
std::fs::write(&path, &token).with_context(|| format!("write {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(token)
}
pub fn default_index_dir() -> String {
data_home().join("comindly").to_string_lossy().into_owned()
}
pub fn init(force: bool) -> Result<PathBuf> {
let path = config_path();
if path.exists() && !force {
anyhow::bail!("config already exists at {}", path.display());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let body = format!(
"# Comindly configuration. CLI flags and environment variables override these.\n\
# Secrets (OPENAI_API_KEY) belong in the environment, not here.\n\n\
index_dir = \"{}\"\n\
llm_model = \"{}\"\n\
embed_model = \"{}\"\n\
format = \"md\" # default output when --format is absent (search: md|table, serve: md|json)\n\
# llm_base_url = \"http://localhost:11434/v1\" # Ollama / vLLM / LiteLLM proxy\n\n\
# index/link step defaults (CLI --x / --no-x override). enrich/flows/guide call an LLM\n\
# (cost + sends code; need OPENAI_API_KEY). Set false to skip; they also skip with a\n\
# warning if no key is set.\n\
embed = true\n\
enrich = true\n\
flows = true\n\
guide = true\n\n\
# SCIP (compiler-grade resolution): auto = use index.scip or installed indexers,\n\
# require = fail without it, off = tree-sitter only.\n\
scip = \"auto\"\n\n\
# Cost caps for LLM steps. Omit for no cap (cover the whole codebase).\n\
# max_enrich = 200 # max symbols enriched by --enrich\n\
# max_flows = 50 # max flows narrated by --flows\n",
default_index_dir(),
crate::llm::DEFAULT_MODEL,
crate::embed::DEFAULT_MODEL,
);
std::fs::write(&path, body).with_context(|| format!("write {}", path.display()))?;
Ok(path)
}
fn non_empty_env(key: &str) -> Option<String> {
env(key).filter(|v| !v.is_empty())
}
pub fn env(key: &str) -> Option<String> {
std::env::var(key).ok()
}
fn home() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_default()
}
fn config_home() -> PathBuf {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| home().join(".config"))
}
fn data_home() -> PathBuf {
std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| home().join(".local").join("share"))
}
pub fn expand_tilde(s: &str) -> String {
if let Some(rest) = s.strip_prefix("~/") {
return home().join(rest).to_string_lossy().into_owned();
}
if s == "~" {
return home().to_string_lossy().into_owned();
}
s.to_string()
}