use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::{HaiError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
pub jacs_agent_name: String,
pub jacs_agent_version: String,
pub jacs_key_dir: PathBuf,
pub jacs_id: Option<String>,
pub jacs_private_key_path: Option<PathBuf>,
pub source_path: PathBuf,
}
pub fn load_config(path: Option<&Path>) -> Result<AgentConfig> {
let source_path = resolve_config_path(path);
if !source_path.is_file() {
return Err(HaiError::ConfigNotFound {
path: source_path.display().to_string(),
});
}
let raw = fs::read_to_string(&source_path).map_err(|e| HaiError::ConfigInvalid {
message: format!("failed to read {}: {e}", source_path.display()),
})?;
let data: Value = serde_json::from_str(&raw)?;
let config_dir = source_path.parent().unwrap_or_else(|| Path::new("."));
let jacs_agent_name = get_string(&data, &["jacsAgentName", "agent_name"]).ok_or_else(|| {
HaiError::ConfigInvalid {
message: "jacsAgentName (or agent_name) is required but missing".to_string(),
}
})?;
let jacs_agent_version = get_string(&data, &["jacsAgentVersion", "agent_version"])
.unwrap_or_else(|| "1.0.0".to_string());
let key_dir_raw =
get_string(&data, &["jacsKeyDir", "key_dir"]).unwrap_or_else(|| ".".to_string());
let jacs_key_dir = if Path::new(&key_dir_raw).is_absolute() {
PathBuf::from(key_dir_raw)
} else {
config_dir.join(key_dir_raw)
};
let jacs_id = get_string(&data, &["jacsId", "jacs_id"]);
if jacs_id.is_none() {
return Err(HaiError::ConfigInvalid {
message: "jacsId (or jacs_id) is required but missing".to_string(),
});
}
let private_key_raw = get_string(&data, &["jacsPrivateKeyPath", "private_key_path"]);
let jacs_private_key_path = private_key_raw.map(|p| {
if Path::new(&p).is_absolute() {
PathBuf::from(p)
} else {
config_dir.join(p)
}
});
Ok(AgentConfig {
jacs_agent_name,
jacs_agent_version,
jacs_key_dir,
jacs_id,
jacs_private_key_path,
source_path,
})
}
pub fn resolve_private_key_candidates(config: &AgentConfig) -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Some(explicit) = &config.jacs_private_key_path {
candidates.push(explicit.clone());
}
candidates.push(config.jacs_key_dir.join("agent_private_key.pem"));
candidates.push(
config
.jacs_key_dir
.join(format!("{}.private.pem", config.jacs_agent_name)),
);
candidates.push(config.jacs_key_dir.join("private_key.pem"));
candidates
}
fn resolve_config_path(path: Option<&Path>) -> PathBuf {
if let Some(path) = path {
return path.to_path_buf();
}
if let Ok(path) = env::var("JACS_CONFIG_PATH") {
if !path.is_empty() {
return PathBuf::from(path);
}
}
PathBuf::from("./jacs.config.json")
}
pub fn resolve_storage_backend_label(label: &str) -> Result<String> {
match label {
"fs" => Ok("fs".to_string()),
"rusqlite" | "sqlite" => Ok("rusqlite".to_string()),
other => Err(HaiError::ConfigInvalid {
message: format!(
"Unsupported storage backend '{}'. Valid routed labels: fs, rusqlite, sqlite",
other
),
}),
}
}
pub fn resolve_storage_backend(
explicit: Option<&str>,
config_path: Option<&Path>,
) -> Result<String> {
if let Some(label) = explicit {
return resolve_storage_backend_label(label);
}
if let Ok(label) = env::var("JACS_STORAGE") {
if !label.is_empty() {
return resolve_storage_backend_label(&label);
}
}
let config_path_resolved = resolve_config_path(config_path);
if config_path_resolved.is_file() {
if let Ok(raw) = fs::read_to_string(&config_path_resolved) {
if let Ok(data) = serde_json::from_str::<Value>(&raw) {
if let Some(label) = get_string(&data, &["default_storage", "defaultStorage"]) {
return resolve_storage_backend_label(&label);
}
}
}
}
Ok("fs".to_string())
}
#[derive(Debug, Clone, Serialize)]
pub struct StorageConfigSummary {
pub backend: String,
pub source: &'static str,
}
impl std::fmt::Display for StorageConfigSummary {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "backend={} (from {})", self.backend, self.source)
}
}
pub fn redacted_display(explicit: Option<&str>, config_path: Option<&Path>) -> StorageConfigSummary {
if let Some(label) = explicit {
return StorageConfigSummary {
backend: label.to_string(),
source: "--storage flag",
};
}
if let Ok(label) = env::var("JACS_STORAGE") {
if !label.is_empty() {
return StorageConfigSummary {
backend: label,
source: "JACS_STORAGE env var",
};
}
}
let config_path_resolved = resolve_config_path(config_path);
if config_path_resolved.is_file() {
if let Ok(raw) = fs::read_to_string(&config_path_resolved) {
if let Ok(data) = serde_json::from_str::<Value>(&raw) {
if let Some(label) = get_string(&data, &["default_storage", "defaultStorage"]) {
return StorageConfigSummary {
backend: label,
source: "config file",
};
}
}
}
}
StorageConfigSummary {
backend: "fs".to_string(),
source: "default",
}
}
fn get_string(data: &Value, keys: &[&str]) -> Option<String> {
for key in keys {
if let Some(value) = data.get(key).and_then(Value::as_str) {
return Some(value.to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
#[test]
fn resolves_relative_paths_from_config_location() {
let temp = tempfile::tempdir().expect("tempdir");
let config_path = temp.path().join("nested").join("jacs.config.json");
fs::create_dir_all(config_path.parent().expect("parent")).expect("mkdir");
fs::write(
&config_path,
r#"{
"jacsAgentName": "agent",
"jacsAgentVersion": "1.0.0",
"jacsKeyDir": "./keys",
"jacsPrivateKeyPath": "./custom/private.pem",
"jacsId": "agent-1"
}"#,
)
.expect("write config");
let cfg = load_config(Some(&config_path)).expect("load");
assert!(cfg.jacs_key_dir.ends_with("nested/keys"));
assert!(cfg
.jacs_private_key_path
.expect("private key path")
.ends_with("nested/custom/private.pem"));
}
#[test]
fn load_config_errors_when_jacs_agent_name_missing() {
let temp = tempfile::tempdir().expect("tempdir");
let config_path = temp.path().join("jacs.config.json");
fs::write(
&config_path,
r#"{"jacsId": "agent-1", "jacsAgentVersion": "2.0.0"}"#,
)
.expect("write config");
let result = load_config(Some(&config_path));
assert!(result.is_err(), "missing jacs_agent_name should be an error");
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("jacsAgentName") || err.contains("agent_name"),
"error should mention the missing field: {err}"
);
}
#[test]
fn load_config_errors_when_jacs_id_missing() {
let temp = tempfile::tempdir().expect("tempdir");
let config_path = temp.path().join("jacs.config.json");
fs::write(
&config_path,
r#"{"jacsAgentName": "my-agent", "jacsAgentVersion": "2.0.0"}"#,
)
.expect("write config");
let result = load_config(Some(&config_path));
assert!(result.is_err(), "missing jacs_id should be an error");
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("jacsId") || err.contains("jacs_id"),
"error should mention the missing field: {err}"
);
}
#[test]
fn load_config_defaults_version_and_key_dir() {
let temp = tempfile::tempdir().expect("tempdir");
let config_path = temp.path().join("jacs.config.json");
fs::write(
&config_path,
r#"{"jacsAgentName": "my-agent", "jacsId": "agent-1"}"#,
)
.expect("write config");
let cfg = load_config(Some(&config_path)).expect("should succeed with defaults for version and key_dir");
assert_eq!(cfg.jacs_agent_version, "1.0.0");
assert_eq!(cfg.jacs_agent_name, "my-agent");
assert_eq!(cfg.jacs_id, Some("agent-1".to_string()));
}
#[test]
fn redacted_display_explicit_flag() {
let summary = redacted_display(Some("rusqlite"), None);
assert_eq!(summary.backend, "rusqlite");
assert_eq!(summary.source, "--storage flag");
let rendered = format!("{}", summary);
assert!(rendered.contains("rusqlite"));
assert!(rendered.contains("--storage flag"));
}
#[test]
fn redacted_display_default_fallback() {
let orig = env::var("JACS_STORAGE").ok();
env::remove_var("JACS_STORAGE");
let summary = redacted_display(None, Some(Path::new("/nonexistent/path.json")));
assert_eq!(summary.backend, "fs");
assert_eq!(summary.source, "default");
if let Some(val) = orig {
env::set_var("JACS_STORAGE", val);
}
}
#[test]
fn redacted_display_from_config_file() {
let temp = tempfile::tempdir().expect("tempdir");
let config_path = temp.path().join("jacs.config.json");
fs::write(
&config_path,
r#"{"default_storage": "sqlite", "jacsAgentName": "test"}"#,
)
.expect("write config");
let orig = env::var("JACS_STORAGE").ok();
env::remove_var("JACS_STORAGE");
let summary = redacted_display(None, Some(&config_path));
assert_eq!(summary.backend, "sqlite");
assert_eq!(summary.source, "config file");
if let Some(val) = orig {
env::set_var("JACS_STORAGE", val);
}
}
}