1use serde::{Deserialize, Serialize};
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, Serialize, Deserialize)]
5pub struct TracevaultConfig {
6 pub agent: String,
7 pub server_url: Option<String>,
8 pub api_key: Option<String>,
9 pub org_slug: Option<String>,
10 pub repo_id: Option<String>,
11}
12
13impl Default for TracevaultConfig {
14 fn default() -> Self {
15 Self {
16 agent: "claude-code".to_string(),
17 server_url: None,
18 api_key: None,
19 org_slug: None,
20 repo_id: None,
21 }
22 }
23}
24
25impl TracevaultConfig {
26 pub fn config_dir(project_root: &Path) -> PathBuf {
27 project_root.join(".tracevault")
28 }
29
30 pub fn config_path(project_root: &Path) -> PathBuf {
31 Self::config_dir(project_root).join("config.toml")
32 }
33
34 pub fn to_toml(&self) -> String {
35 let mut out = format!("# TraceVault configuration\nagent = \"{}\"\n", self.agent);
36 if let Some(url) = &self.server_url {
37 out.push_str(&format!("server_url = \"{url}\"\n"));
38 }
39 if let Some(slug) = &self.org_slug {
40 out.push_str(&format!("org_slug = \"{slug}\"\n"));
41 }
42 if let Some(rid) = &self.repo_id {
43 out.push_str(&format!("repo_id = \"{rid}\"\n"));
44 }
45 out
46 }
47
48 pub fn load(project_root: &Path) -> Option<Self> {
51 let path = Self::config_path(project_root);
52 let content = std::fs::read_to_string(path).ok()?;
53
54 let parse_field = |key: &str| -> Option<String> {
55 content
56 .lines()
57 .find(|l| l.starts_with(key))
58 .and_then(|l| l.split('=').nth(1))
59 .map(|s| s.trim().trim_matches('"').to_string())
60 };
61
62 Some(Self {
63 agent: parse_field("agent").unwrap_or_else(|| "claude-code".to_string()),
64 server_url: parse_field("server_url"),
65 api_key: parse_field("api_key"),
66 org_slug: parse_field("org_slug"),
67 repo_id: parse_field("repo_id"),
68 })
69 }
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75 use std::fs;
76
77 #[test]
78 fn to_toml_all_fields() {
79 let cfg = TracevaultConfig {
80 agent: "claude-code".into(),
81 server_url: Some("https://example.com".into()),
82 api_key: None, org_slug: Some("my-org".into()),
84 repo_id: Some("repo-1".into()),
85 };
86 let toml = cfg.to_toml();
87 assert!(toml.contains("agent = \"claude-code\""));
88 assert!(toml.contains("server_url = \"https://example.com\""));
89 assert!(toml.contains("org_slug = \"my-org\""));
90 assert!(toml.contains("repo_id = \"repo-1\""));
91 }
92
93 #[test]
94 fn to_toml_minimal() {
95 let cfg = TracevaultConfig::default();
96 let toml = cfg.to_toml();
97 assert!(toml.contains("agent = \"claude-code\""));
98 assert!(!toml.contains("server_url"));
99 }
100
101 #[test]
102 fn load_valid_config() {
103 let dir = tempfile::tempdir().unwrap();
104 let tv_dir = dir.path().join(".tracevault");
105 fs::create_dir_all(&tv_dir).unwrap();
106 fs::write(
107 tv_dir.join("config.toml"),
108 "agent = \"claude-code\"\nserver_url = \"https://example.com\"\norg_slug = \"myorg\"\n",
109 )
110 .unwrap();
111 let cfg = TracevaultConfig::load(dir.path()).unwrap();
112 assert_eq!(cfg.agent, "claude-code");
113 assert_eq!(cfg.server_url.unwrap(), "https://example.com");
114 assert_eq!(cfg.org_slug.unwrap(), "myorg");
115 }
116
117 #[test]
118 fn load_missing_file_returns_none() {
119 let dir = tempfile::tempdir().unwrap();
120 assert!(TracevaultConfig::load(dir.path()).is_none());
121 }
122}