use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use crate::kb::{KbMode, KbSpec};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default = "default_mode_default")]
pub default_mode: KbMode,
#[serde(default)]
pub serve: ServeConfig,
#[serde(default)]
pub kbs: BTreeMap<String, StoredKb>,
}
fn default_mode_default() -> KbMode {
KbMode::Global
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredKb {
pub mode: KbMode,
pub kb_dir: PathBuf,
#[serde(default)]
pub created: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ServeConfig {
pub profile: Option<String>,
pub strip_tool_descriptions: Option<String>,
pub mega_tool: Option<bool>,
pub wrapper_cache_size: Option<usize>,
}
impl Default for Config {
fn default() -> Self {
Self {
default_mode: KbMode::Global,
serve: ServeConfig::default(),
kbs: BTreeMap::new(),
}
}
}
impl Config {
pub fn path() -> Result<PathBuf> {
let pd = ProjectDirs::from("", "", "heliosdb-codekb-mcp")
.context("could not resolve config directory")?;
let dir = pd.config_dir();
std::fs::create_dir_all(dir)
.with_context(|| format!("failed to create {}", dir.display()))?;
Ok(dir.join("config.toml"))
}
pub fn load_or_default() -> Result<Self> {
let path = Self::path()?;
if !path.exists() {
return Ok(Self::default());
}
let text = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
toml::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))
}
pub fn save(&self) -> Result<()> {
let path = Self::path()?;
let text = self.to_toml()?;
std::fs::write(&path, text)
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
pub fn to_toml(&self) -> Result<String> {
toml::to_string_pretty(self).context("failed to serialise config")
}
pub fn upsert_kb(&mut self, source: &Path, spec: KbSpec) {
let key = source.to_string_lossy().into_owned();
self.kbs.insert(
key,
StoredKb {
mode: spec.mode,
kb_dir: spec.kb_dir,
created: now_iso(),
},
);
}
pub fn lookup_for_source(&self, query: &Path) -> Option<KbSpec> {
let q = query.canonicalize().ok()?;
let q_str = q.to_string_lossy();
let mut best: Option<(&String, &StoredKb)> = None;
for (k, v) in &self.kbs {
if q_str == k.as_str() || q_str.starts_with(&format!("{k}/")) {
let take = match best {
Some((bk, _)) => k.len() > bk.len(),
None => true,
};
if take {
best = Some((k, v));
}
}
}
best.map(|(_, v)| KbSpec {
mode: v.mode,
kb_dir: v.kb_dir.clone(),
})
}
}
fn now_iso() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let (y, mo, d, h, mi, s) = epoch_to_ymdhms(secs);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
}
fn epoch_to_ymdhms(mut s: u64) -> (i32, u32, u32, u32, u32, u32) {
let sec = (s % 60) as u32;
s /= 60;
let mi = (s % 60) as u32;
s /= 60;
let h = (s % 24) as u32;
s /= 24;
let mut days = s as i64;
let mut year = 1970i32;
loop {
let leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
let dy = if leap { 366 } else { 365 };
if days < dy {
break;
}
days -= dy;
year += 1;
}
let leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
let months = [
31,
if leap { 29 } else { 28 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let mut mo = 1u32;
for &dim in &months {
if days < dim {
break;
}
days -= dim;
mo += 1;
}
(year, mo, (days + 1) as u32, h, mi, sec)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn default_round_trip() {
let cfg = Config::default();
let s = cfg.to_toml().unwrap();
let parsed: Config = toml::from_str(&s).unwrap();
assert_eq!(parsed.default_mode, KbMode::Global);
assert!(parsed.kbs.is_empty());
}
#[test]
fn serve_section_round_trips() {
let mut cfg = Config::default();
cfg.serve.profile = Some("minimal".to_string());
cfg.serve.strip_tool_descriptions = Some("all".to_string());
cfg.serve.mega_tool = Some(true);
cfg.serve.wrapper_cache_size = Some(64);
let s = cfg.to_toml().unwrap();
let parsed: Config = toml::from_str(&s).unwrap();
assert_eq!(parsed.serve.profile.as_deref(), Some("minimal"));
assert_eq!(parsed.serve.strip_tool_descriptions.as_deref(), Some("all"));
assert_eq!(parsed.serve.mega_tool, Some(true));
assert_eq!(parsed.serve.wrapper_cache_size, Some(64));
}
#[test]
fn missing_serve_section_is_default() {
let legacy = r#"default_mode = "global""#;
let parsed: Config = toml::from_str(legacy).unwrap();
assert!(parsed.serve.profile.is_none());
assert!(parsed.serve.strip_tool_descriptions.is_none());
assert!(parsed.serve.mega_tool.is_none());
assert!(parsed.serve.wrapper_cache_size.is_none());
}
#[test]
fn upsert_and_lookup() {
let mut cfg = Config::default();
cfg.upsert_kb(
&PathBuf::from("/tmp/example-repo"),
KbSpec {
mode: KbMode::CoLocated,
kb_dir: PathBuf::from("/tmp/example-repo/.helios-kb"),
},
);
assert_eq!(cfg.kbs.len(), 1);
assert!(cfg.kbs.contains_key("/tmp/example-repo"));
}
}