Skip to main content

comindly_engine/
config.rs

1//! Your preferences on this machine — which index to open, the repositories this machine indexes,
2//! and how your ledger records are named and signed — kept in a versioned LanceDB table in
3//! comindly's config folder. A TOML file of the same shape can be imported and exported. What an
4//! index runs with is stored with the index; a server keeps its settings in its database.
5//!
6//! Precedence, highest first: CLI flag → environment variable → these preferences → default.
7
8use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10use std::path::PathBuf;
11
12/// Non-secret preferences, all optional.
13#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
14pub struct Config {
15    /// Default index directory for `--index-dir` (local path or `s3://…`).
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub index_dir: Option<String>,
18    /// SSH key that signs ledger records (default: git `user.signingkey` when `gpg.format=ssh`).
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub signing_key: Option<String>,
21    /// `allowed_signers` file for verifying records (default: git `gpg.ssh.allowedSignersFile`).
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub allowed_signers: Option<String>,
24    /// Seconds between freshness checks of the local mirror of a remote index (default 300).
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub cache_ttl_secs: Option<u64>,
27    /// Name shown on ledger records you write (default: git `user.name`).
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub author_name: Option<String>,
30    /// `false` keeps names off new ledger records (only the pseudonymous actor id is stored).
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub record_names: Option<bool>,
33    /// Repositories `comindly link` indexes when given none: comindly checks out each one's default
34    /// branch in its own worktree.
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub repos: Vec<RepoEntry>,
37}
38
39/// The preferences, by the names files and `comindly config set` use.
40pub 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
52/// `repos` with `~` in their paths expanded.
53pub 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
65/// Where the preferences are kept: `<config folder>/settings`.
66pub fn store_path() -> PathBuf {
67    config_dir().join("settings")
68}
69
70/// The stored preferences with their version.
71pub 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
83/// Every saved version of the preferences, newest first.
84pub 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
89/// Replace the repository list.
90pub 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    /// The stored preferences; on a read error, a warning and the defaults.
99    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    /// Resolve the index location: flag → `COMINDLY_INDEX_DIR` → preferences → `~/.local/share/comindly`.
110    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    /// Resolve the graph dataset to read from: `<index_dir>/_graph`. The `_graph` suffix is an
124    /// internal detail — callers pass the index dir (root), never the dataset path.
125    pub fn graph_dir(&self, flag: Option<&str>) -> String {
126        format!("{}/_graph", self.index_dir(flag).trim_end_matches('/'))
127    }
128
129    /// How often long-running readers (`serve`, `ui`, the hooks' local mirror) check for a new
130    /// index version.
131    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
136/// Where older versions kept preferences as a file; imported once if found.
137pub fn config_path() -> PathBuf {
138    config_dir().join("config.toml")
139}
140
141/// comindly's config folder.
142pub fn config_dir() -> PathBuf {
143    config_home().join("comindly")
144}
145
146/// A setting to write: a string, a flag or a number; `Remove` deletes the key.
147pub enum Setting {
148    Text(String),
149    Flag(bool),
150    Number(i64),
151    Remove,
152}
153
154/// Apply `changes` to the stored preferences.
155pub 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
179/// The web UI's access token for browsers: created once, so bookmarks keep working.
180pub 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
198/// `$XDG_DATA_HOME/comindly`, else `~/.local/share/comindly`: the default index and managed clones.
199/// comindly's cache folder on this machine: worktrees, verified signatures.
200pub 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
208/// The default index location: the data folder itself.
209pub 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
217/// An environment variable, if set.
218pub 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
242/// Expand a leading `~` to `$HOME`; leave URIs (`s3://…`) and absolute paths untouched.
243pub 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
253/// The settings an index runs with here: its shared settings, then this machine's.
254pub 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
262/// An LLM client for `settings`, with the key for `team` from this machine or `server`.
263pub 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
282/// This machine's signing key: `COMINDLY_SIGNING_KEY` → `signing_key` → git `user.signingkey` when
283/// `gpg.format=ssh`.
284pub 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
296/// `COMINDLY_ALLOWED_SIGNERS` → `allowed_signers` → git `gpg.ssh.allowedSignersFile`.
297pub 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
309/// Signature checks with this machine's `allowed_signers`.
310/// The deployment's token for `host`: `GH_TOKEN`/`GITHUB_TOKEN` for github.com, `GITLAB_TOKEN` for
311/// gitlab.com and the hosts in `COMINDLY_GITLAB_HOSTS` (or their `_FILE`).
312pub 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
330/// Git tokens for indexing: on a server the ones stored there, then the deployment's.
331pub 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
344/// Who this machine records as: a pseudonymous actor id, and a name unless names are switched off
345/// (`COMINDLY_AUTHOR_NAME` → `author_name` → git `user.name`).
346pub 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/// Settings older versions kept in `config.toml` that now belong to the index.
378#[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
409/// Fill what `team` leaves unset from settings an older `config.toml` held; `true` if any.
410pub 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
440/// Keys older files may carry that nothing reads any more; imports skip them.
441const OBSOLETE: [&str; 2] = ["aws_profile", "sso_auto_login"];
442
443/// What an import changed.
444#[derive(Debug, Default)]
445pub struct Imported {
446    pub changed: bool,
447    /// Preferences already set to something else, left as they were.
448    pub kept: Vec<String>,
449    /// Index settings the file carried, moved into the index's shared settings.
450    pub moved: bool,
451}
452
453/// Import a preferences file. Without `overwrite` it fills only what is unset; with it, the file
454/// replaces the preferences. Index settings older files carried move to the index once.
455pub 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
520/// The preferences as a TOML file that [`import`] reads back.
521pub 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
528/// An older `config.toml` is imported once and renamed; one that appears later is pointed out.
529pub 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
562/// Save an index's shared settings as you, if nobody saved since `expected`.
563pub 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}