use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{
OlError, OL_4262_CONSENT_FILE_CORRUPT, OL_4272_XDG_DIR_UNWRITABLE, OL_4273_MANIFEST_UNREADABLE,
};
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub telemetry: TelemetrySection,
#[serde(default)]
pub crashreport: CrashreportSection,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub profiles: std::collections::BTreeMap<String, ProfileSection>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct TelemetrySection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub machine_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrashreportSection {
#[serde(default = "default_true")]
pub enabled: bool,
}
impl Default for CrashreportSection {
fn default() -> Self {
Self { enabled: true }
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct ProfileSection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest_slug: Option<String>,
}
pub fn provider_dir() -> PathBuf {
static DIR: OnceLock<PathBuf> = OnceLock::new();
DIR.get_or_init(compute_provider_dir).clone()
}
fn compute_provider_dir() -> PathBuf {
if let Ok(override_dir) = std::env::var("OPENLATCH_PROVIDER_CONFIG_DIR") {
return PathBuf::from(override_dir);
}
if let Some(home) = dirs::home_dir() {
return home.join(".openlatch").join("provider");
}
PathBuf::from(".openlatch").join("provider")
}
pub fn config_path() -> PathBuf {
provider_dir().join("config.toml")
}
pub fn manifest_path_for_slug(slug: &str) -> PathBuf {
provider_dir().join(format!("{slug}.yaml"))
}
pub fn active_manifest_path(profile: Option<&str>) -> Result<PathBuf, OlError> {
let cfg = Config::load()?;
let profile_name = profile.unwrap_or("default");
let slug = cfg
.profiles
.get(profile_name)
.and_then(|p| p.manifest_slug.as_deref())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
OlError::new(
OL_4273_MANIFEST_UNREADABLE,
format!(
"no manifest configured for profile `{profile_name}` (config.toml has no \
`[profiles.{profile_name}] manifest_slug`)"
),
)
.with_suggestion(
"Run `openlatch-provider init` to scaffold a manifest, or pass \
`--manifest <path>` to use one explicitly.",
)
})?;
Ok(manifest_path_for_slug(slug))
}
pub fn set_manifest_slug(profile: Option<&str>, slug: &str) -> Result<(), OlError> {
let mut cfg = Config::load()?;
let profile_name = profile.unwrap_or("default").to_string();
cfg.profiles.entry(profile_name).or_default().manifest_slug = Some(slug.to_string());
cfg.save()
}
impl Config {
pub fn load() -> Result<Self, OlError> {
Self::load_from(&config_path())
}
pub fn load_from(path: &Path) -> Result<Self, OlError> {
match fs::read_to_string(path) {
Ok(raw) => toml::from_str(&raw).map_err(|e| {
OlError::new(
OL_4262_CONSENT_FILE_CORRUPT,
format!("config.toml at '{}' is malformed: {e}", path.display()),
)
.with_suggestion(
"Delete the file and re-run any openlatch-provider command to regenerate it.",
)
}),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(OlError::new(
OL_4262_CONSENT_FILE_CORRUPT,
format!("cannot read config.toml at '{}': {e}", path.display()),
)),
}
}
pub fn save(&self) -> Result<(), OlError> {
Self::save_to(self, &config_path())
}
pub fn save_to(&self, path: &Path) -> Result<(), OlError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
OlError::new(
OL_4272_XDG_DIR_UNWRITABLE,
format!("cannot create '{}': {e}", parent.display()),
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
}
}
let serialized = toml::to_string_pretty(self).map_err(|e| {
OlError::new(
OL_4272_XDG_DIR_UNWRITABLE,
format!("cannot serialize config.toml: {e}"),
)
})?;
let tmp = path.with_extension("toml.tmp");
fs::write(&tmp, serialized.as_bytes()).map_err(|e| {
OlError::new(
OL_4272_XDG_DIR_UNWRITABLE,
format!("cannot write '{}': {e}", tmp.display()),
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600));
}
fs::rename(&tmp, path).map_err(|e| {
OlError::new(
OL_4272_XDG_DIR_UNWRITABLE,
format!("cannot rename '{}' into place: {e}", tmp.display()),
)
})?;
Ok(())
}
}
pub fn machine_id_or_init() -> Result<String, OlError> {
machine_id_or_init_in(&config_path())
}
pub fn machine_id_or_init_in(path: &Path) -> Result<String, OlError> {
let mut cfg = Config::load_from(path)?;
if let Some(id) = cfg.telemetry.machine_id.as_ref() {
if !id.is_empty() {
return Ok(id.clone());
}
}
let id = format!("mach_{}", Uuid::now_v7().simple());
cfg.telemetry.machine_id = Some(id.clone());
cfg.save_to(path)?;
Ok(id)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn machine_id_round_trips() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
let first = machine_id_or_init_in(&path).unwrap();
assert!(first.starts_with("mach_"));
let second = machine_id_or_init_in(&path).unwrap();
assert_eq!(first, second);
}
#[test]
fn missing_file_yields_default_config() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("absent.toml");
let cfg = Config::load_from(&path).unwrap();
assert!(cfg.telemetry.machine_id.is_none());
assert!(cfg.crashreport.enabled);
}
#[test]
fn malformed_file_returns_corrupt_error() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("bad.toml");
std::fs::write(&path, b"not = a [valid toml").unwrap();
let err = Config::load_from(&path).unwrap_err();
assert_eq!(err.code.code, "OL-4262");
}
#[test]
fn manifest_slug_round_trips_through_toml() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
let mut cfg = Config::default();
cfg.profiles.insert(
"default".to_string(),
ProfileSection {
manifest_slug: Some("my-editor".into()),
..Default::default()
},
);
cfg.save_to(&path).unwrap();
let reloaded = Config::load_from(&path).unwrap();
assert_eq!(
reloaded
.profiles
.get("default")
.and_then(|p| p.manifest_slug.as_deref()),
Some("my-editor"),
);
}
#[test]
fn manifest_path_for_slug_appends_yaml_extension() {
let path = manifest_path_for_slug("my-editor");
assert!(path.ends_with("my-editor.yaml"));
}
#[test]
fn default_provider_dir_is_under_home_openlatch_provider() {
std::env::remove_var("OPENLATCH_PROVIDER_CONFIG_DIR");
let path = compute_provider_dir();
let s = path.to_string_lossy().replace('\\', "/");
assert!(
s.ends_with("/.openlatch/provider") || s == ".openlatch/provider",
"expected default to end with .openlatch/provider, got: {s}"
);
}
}