1use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10use std::path::PathBuf;
11
12#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
14pub struct Config {
15 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub index_dir: Option<String>,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub signing_key: Option<String>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub allowed_signers: Option<String>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub cache_ttl_secs: Option<u64>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub author_name: Option<String>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub record_names: Option<bool>,
33 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub repos: Vec<RepoEntry>,
37}
38
39pub const KEYS: [&str; 7] = [
41 "index_dir",
42 "signing_key",
43 "allowed_signers",
44 "cache_ttl_secs",
45 "author_name",
46 "record_names",
47 "repos",
48];
49
50pub use crate::pipeline::RepoEntry;
51
52pub fn expanded(repos: &[RepoEntry]) -> Vec<RepoEntry> {
54 repos
55 .iter()
56 .map(|r| RepoEntry {
57 path: expand_tilde(&r.path),
58 remote: r.remote.clone(),
59 })
60 .collect()
61}
62
63const TABLE: &str = "preferences";
64
65pub fn store_path() -> PathBuf {
67 config_dir().join("settings")
68}
69
70pub fn stored() -> Result<crate::docstore::Stored<Config>> {
72 let uri = store_path().to_string_lossy().into_owned();
73 crate::rt::block_on(crate::docstore::load(&uri, TABLE))
74}
75
76fn save(cfg: &Config, expected: Option<u64>) -> Result<u64> {
77 let uri = store_path().to_string_lossy().into_owned();
78 let me = local_identity();
79 let by = me.name.unwrap_or(me.actor);
80 crate::rt::block_on(crate::docstore::save(&uri, TABLE, cfg, expected, &by))
81}
82
83pub fn history() -> Result<Vec<crate::docstore::Version>> {
85 let uri = store_path().to_string_lossy().into_owned();
86 crate::rt::block_on(crate::docstore::history(&uri, TABLE))
87}
88
89pub fn set_repos(list: &[RepoEntry]) -> Result<()> {
91 let s = stored()?;
92 let mut cfg = s.doc;
93 cfg.repos = list.to_vec();
94 save(&cfg, s.version).map(drop)
95}
96
97impl Config {
98 pub fn load() -> Self {
100 match stored() {
101 Ok(s) => s.doc,
102 Err(e) => {
103 crate::ui::warn(&format!("your preferences could not be read: {e:#}"));
104 Self::default()
105 }
106 }
107 }
108
109 pub fn index_dir(&self, flag: Option<&str>) -> String {
111 if let Some(f) = flag {
112 return expand_tilde(f);
113 }
114 if let Some(e) = non_empty_env("COMINDLY_INDEX_DIR") {
115 return expand_tilde(&e);
116 }
117 if let Some(d) = &self.index_dir {
118 return expand_tilde(d);
119 }
120 default_index_dir()
121 }
122
123 pub fn graph_dir(&self, flag: Option<&str>) -> String {
126 format!("{}/_graph", self.index_dir(flag).trim_end_matches('/'))
127 }
128
129 pub fn fresh(&self) -> std::time::Duration {
132 std::time::Duration::from_secs(self.cache_ttl_secs.unwrap_or(crate::cache::DEFAULT_TTL))
133 }
134}
135
136pub fn config_path() -> PathBuf {
138 config_dir().join("config.toml")
139}
140
141pub fn config_dir() -> PathBuf {
143 config_home().join("comindly")
144}
145
146pub enum Setting {
148 Text(String),
149 Flag(bool),
150 Number(i64),
151 Remove,
152}
153
154pub fn update(changes: &[(&str, Setting)]) -> Result<()> {
156 let s = stored()?;
157 let mut doc = serde_json::to_value(&s.doc)?;
158 let map = doc.as_object_mut().context("preferences")?;
159 for (key, value) in changes {
160 anyhow::ensure!(
161 KEYS.contains(key) && *key != "repos",
162 "unknown preference `{key}` (one of {})",
163 KEYS[..6].join(", ")
164 );
165 match value {
166 Setting::Text(v) => drop(map.insert(key.to_string(), v.as_str().into())),
167 Setting::Flag(v) => drop(map.insert(key.to_string(), (*v).into())),
168 Setting::Number(v) => drop(map.insert(key.to_string(), (*v).into())),
169 Setting::Remove => drop(map.remove(*key)),
170 }
171 }
172 let next: Config = serde_json::from_value(doc).context("preferences")?;
173 if next != s.doc {
174 save(&next, s.version)?;
175 }
176 Ok(())
177}
178
179pub fn ui_token() -> Result<String> {
181 let path = config_dir().join("ui-token");
182 if let Ok(t) = std::fs::read_to_string(&path) {
183 if !t.trim().is_empty() {
184 return Ok(t.trim().to_string());
185 }
186 }
187 std::fs::create_dir_all(path.parent().context("config dir")?)?;
188 let token = crate::web::new_token();
189 std::fs::write(&path, &token).with_context(|| format!("write {}", path.display()))?;
190 #[cfg(unix)]
191 {
192 use std::os::unix::fs::PermissionsExt;
193 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
194 }
195 Ok(token)
196}
197
198pub fn cache_dir() -> PathBuf {
201 home_path("~/.cache/comindly")
202}
203
204pub fn data_dir() -> String {
205 data_home().join("comindly").to_string_lossy().into_owned()
206}
207
208pub fn default_index_dir() -> String {
210 data_dir()
211}
212
213fn non_empty_env(key: &str) -> Option<String> {
214 env(key).filter(|v| !v.is_empty())
215}
216
217pub fn env(key: &str) -> Option<String> {
219 std::env::var(key).ok()
220}
221
222fn home() -> PathBuf {
223 std::env::var_os("HOME")
224 .map(PathBuf::from)
225 .unwrap_or_default()
226}
227
228fn config_home() -> PathBuf {
229 std::env::var_os("XDG_CONFIG_HOME")
230 .map(PathBuf::from)
231 .filter(|p| !p.as_os_str().is_empty())
232 .unwrap_or_else(|| home().join(".config"))
233}
234
235fn data_home() -> PathBuf {
236 std::env::var_os("XDG_DATA_HOME")
237 .map(PathBuf::from)
238 .filter(|p| !p.as_os_str().is_empty())
239 .unwrap_or_else(|| home().join(".local").join("share"))
240}
241
242pub fn expand_tilde(s: &str) -> String {
244 if let Some(rest) = s.strip_prefix("~/") {
245 return home().join(rest).to_string_lossy().into_owned();
246 }
247 if s == "~" {
248 return home().to_string_lossy().into_owned();
249 }
250 s.to_string()
251}
252
253pub async fn index_settings(root: &str) -> crate::context::Settings {
255 let team = crate::team::load(root)
256 .await
257 .map(|s| s.settings)
258 .unwrap_or_default();
259 crate::context::Settings::resolve(&team, crate::config::env)
260}
261
262pub fn llm_client(
264 settings: &crate::context::Settings,
265 team: Option<&str>,
266 server: Option<&crate::server::Server>,
267) -> Option<crate::llm::LlmClient> {
268 let key = crate::credentials::llm_key(team, server).0;
269 crate::llm::LlmClient::new(
270 settings.llm_base_url.as_deref(),
271 &settings.llm_model,
272 key.as_deref(),
273 )
274 .ok()
275 .map(|c| c.with_models(settings.task_models.clone()))
276}
277
278fn home_path(p: &str) -> PathBuf {
279 PathBuf::from(expand_tilde(p))
280}
281
282pub fn local_signing_key() -> Option<PathBuf> {
285 if let Some(k) = env("COMINDLY_SIGNING_KEY").or_else(|| Config::load().signing_key) {
286 return Some(home_path(&k));
287 }
288 let cfg = git2::Config::open_default().ok()?;
289 if cfg.get_string("gpg.format").ok()? != "ssh" {
290 return None;
291 }
292 let k = cfg.get_string("user.signingkey").ok()?;
293 (!k.starts_with("key::")).then(|| home_path(&k))
294}
295
296pub fn local_allowed_signers() -> Option<PathBuf> {
298 let p = env("COMINDLY_ALLOWED_SIGNERS")
299 .or_else(|| Config::load().allowed_signers)
300 .or_else(|| {
301 git2::Config::open_default()
302 .ok()?
303 .get_string("gpg.ssh.allowedSignersFile")
304 .ok()
305 })?;
306 Some(home_path(&p)).filter(|p| p.is_file())
307}
308
309pub fn env_token(host: &str) -> Option<String> {
313 let host = host.to_ascii_lowercase();
314 let gitlab = host == "gitlab.com"
315 || crate::server::env_secret("COMINDLY_GITLAB_HOSTS").is_some_and(|hosts| {
316 hosts
317 .split([',', ' '])
318 .any(|h| h.trim().eq_ignore_ascii_case(&host))
319 });
320 let vars: &[&str] = if host == "github.com" {
321 &["GH_TOKEN", "GITHUB_TOKEN"]
322 } else if gitlab {
323 &["GITLAB_TOKEN"]
324 } else {
325 &[]
326 };
327 vars.iter().find_map(|v| crate::server::env_secret(v))
328}
329
330pub fn git_credentials(
332 server: Option<std::sync::Arc<crate::server::Server>>,
333) -> std::sync::Arc<dyn crate::worktree::Credentials> {
334 std::sync::Arc::new(move |host: &str| match &server {
335 Some(s) => s.git_token(host),
336 None => env_token(host),
337 })
338}
339
340pub fn local_verifier() -> crate::signing::Verifier {
341 crate::signing::Verifier::new(local_allowed_signers(), Some(cache_dir().join("verified")))
342}
343
344pub fn local_identity() -> crate::ledger::Identity {
347 let actor = env("COMINDLY_ACTOR").unwrap_or_else(|| {
348 let who = std::env::var("USER").unwrap_or_default();
349 let host = std::env::var("HOSTNAME")
350 .or_else(|_| std::env::var("HOST"))
351 .unwrap_or_default();
352 let h = blake3::hash(format!("{who}@{host}").as_bytes()).to_hex();
353 format!("actor-{}", &h[..10])
354 });
355 let name = match env("COMINDLY_AUTHOR_NAME") {
356 Some(n) => Some(n),
357 None => {
358 let cfg = Config::load();
359 if cfg.record_names == Some(false) {
360 None
361 } else {
362 cfg.author_name.or_else(|| {
363 git2::Config::open_default()
364 .ok()?
365 .get_string("user.name")
366 .ok()
367 })
368 }
369 }
370 };
371 crate::ledger::Identity {
372 actor,
373 name: name.map(|n| n.trim().to_string()).filter(|n| !n.is_empty()),
374 }
375}
376
377#[derive(Debug, Default, Deserialize)]
379struct Legacy {
380 llm_model: Option<String>,
381 embed_model: Option<String>,
382 llm_base_url: Option<String>,
383 max_enrich: Option<usize>,
384 max_flows: Option<usize>,
385 format: Option<String>,
386 embed: Option<bool>,
387 enrich: Option<bool>,
388 flows: Option<bool>,
389 guide: Option<bool>,
390 scip: Option<String>,
391 auto_index: Option<bool>,
392}
393
394const LEGACY: [&str; 12] = [
395 "llm_model",
396 "embed_model",
397 "llm_base_url",
398 "max_enrich",
399 "max_flows",
400 "format",
401 "embed",
402 "enrich",
403 "flows",
404 "guide",
405 "scip",
406 "auto_index",
407];
408
409pub fn adopt_legacy(text: &str, team: &mut crate::team::TeamSettings) -> bool {
411 let Ok(old) = toml::from_str::<Legacy>(text) else {
412 return false;
413 };
414 let mut changed = false;
415 macro_rules! take {
416 ($($f:ident),*) => {$(
417 if team.$f.is_none() && old.$f.is_some() {
418 team.$f = old.$f.clone();
419 changed = true;
420 }
421 )*};
422 }
423 take!(
424 llm_model,
425 embed_model,
426 llm_base_url,
427 max_enrich,
428 max_flows,
429 format,
430 embed,
431 enrich,
432 flows,
433 guide,
434 scip,
435 auto_index
436 );
437 changed
438}
439
440const OBSOLETE: [&str; 2] = ["aws_profile", "sso_auto_login"];
442
443#[derive(Debug, Default)]
445pub struct Imported {
446 pub changed: bool,
447 pub kept: Vec<String>,
449 pub moved: bool,
451}
452
453pub fn import(text: &str, overwrite: bool) -> Result<Imported> {
456 let table: toml::Table = toml::from_str(text).context("not a TOML file")?;
457 let unknown: Vec<&str> = table
458 .keys()
459 .map(String::as_str)
460 .filter(|k| !KEYS.contains(k) && !LEGACY.contains(k) && !OBSOLETE.contains(k))
461 .collect();
462 anyhow::ensure!(
463 unknown.is_empty(),
464 "unknown setting(s): {} (preferences are {})",
465 unknown.join(", "),
466 KEYS.join(", ")
467 );
468 let file: Config = toml::from_str(text)?;
469 let s = stored()?;
470 let mut out = Imported::default();
471 let next = if overwrite {
472 file
473 } else {
474 let mut next = s.doc.clone();
475 macro_rules! fill {
476 ($($f:ident),*) => {$(
477 match (&next.$f, &file.$f) {
478 (None, Some(_)) => next.$f = file.$f.clone(),
479 (Some(a), Some(b)) if a != b => out.kept.push(stringify!($f).to_string()),
480 _ => {}
481 }
482 )*};
483 }
484 fill!(
485 index_dir,
486 signing_key,
487 allowed_signers,
488 cache_ttl_secs,
489 author_name,
490 record_names
491 );
492 if next.repos.is_empty() {
493 next.repos = file.repos.clone();
494 } else if !file.repos.is_empty() && file.repos != next.repos {
495 out.kept.push("repos".into());
496 }
497 next
498 };
499 if next != s.doc {
500 save(&next, s.version)?;
501 out.changed = true;
502 }
503 let root = next.index_dir(None);
504 if LEGACY.iter().any(|k| table.contains_key(*k)) && !crate::cache::is_remote(&root) {
505 let me = local_identity();
506 let by = me.name.unwrap_or(me.actor);
507 out.moved = crate::rt::block_on(async {
508 let stored = crate::team::load(&root).await?;
509 let mut settings = stored.settings;
510 let moved = adopt_legacy(text, &mut settings);
511 if moved {
512 crate::team::save_as(&root, &settings, stored.version, &by).await?;
513 }
514 anyhow::Ok(moved)
515 })?;
516 }
517 Ok(out)
518}
519
520pub fn export(cfg: &Config) -> Result<String> {
522 Ok(format!(
523 "# comindly preferences: `comindly config import <file> --override` applies this file.\n\n{}",
524 toml::to_string_pretty(cfg)?
525 ))
526}
527
528pub fn adopt_file() {
530 let path = config_path();
531 let Ok(text) = std::fs::read_to_string(&path) else {
532 return;
533 };
534 match stored() {
535 Ok(s) if s.version.is_some() => {
536 crate::ui::warn(&format!(
537 "{} is not read; apply it with `comindly config import {}`",
538 path.display(),
539 path.display()
540 ));
541 return;
542 }
543 Err(_) => return,
544 Ok(_) => {}
545 }
546 let done = import(&text, true).and_then(|_| {
547 Ok(std::fs::rename(
548 &path,
549 path.with_extension("toml.imported"),
550 )?)
551 });
552 match done {
553 Ok(()) => crate::ui::note(&format!(
554 "imported {} into your preferences (`comindly config`); the file is kept as {}",
555 path.display(),
556 path.with_extension("toml.imported").display()
557 )),
558 Err(e) => crate::ui::warn(&format!("could not import {}: {e:#}", path.display())),
559 }
560}
561
562pub async fn save_team(
564 root: &str,
565 settings: &crate::team::TeamSettings,
566 expected: Option<u64>,
567) -> Result<u64> {
568 let me = local_identity();
569 crate::team::save_as(
570 root,
571 settings,
572 expected,
573 me.name.as_deref().unwrap_or(&me.actor),
574 )
575 .await
576}