agent_doc/
project_config.rs1use anyhow::Result;
21use serde::{Deserialize, Serialize};
22use std::collections::BTreeMap;
23use std::path::{Path, PathBuf};
24
25#[derive(Debug, Clone, Serialize, Deserialize, Default)]
27pub struct ComponentConfig {
28 #[serde(default = "default_patch_mode", alias = "mode")]
31 pub patch: String,
32 #[serde(default = "default_merge_strategy")]
37 #[allow(dead_code)]
38 pub merge_strategy: String,
39 #[serde(default)]
41 pub timestamp: bool,
42 #[serde(default)]
44 pub max_entries: usize,
45 #[serde(default)]
48 #[allow(dead_code)]
49 pub max_lines: usize,
50 #[serde(default)]
52 pub pre_patch: Option<String>,
53 #[serde(default)]
55 pub post_patch: Option<String>,
56}
57
58fn default_patch_mode() -> String {
59 "replace".to_string()
60}
61
62fn default_merge_strategy() -> String {
63 "append-friendly".to_string()
64}
65
66#[derive(Debug, Default, Serialize, Deserialize)]
68pub struct ProjectConfig {
69 #[serde(default)]
71 pub tmux_session: Option<String>,
72 #[serde(default)]
74 pub components: BTreeMap<String, ComponentConfig>,
75}
76
77pub fn load_project() -> ProjectConfig {
80 load_project_from(&project_config_path())
81}
82
83pub(crate) fn load_project_from(path: &Path) -> ProjectConfig {
85 let mut config = if path.exists() {
86 match std::fs::read_to_string(path) {
87 Ok(content) => match toml::from_str(&content) {
88 Ok(cfg) => cfg,
89 Err(e) => {
90 eprintln!("warning: failed to parse {}: {}", path.display(), e);
91 ProjectConfig::default()
92 }
93 },
94 Err(e) => {
95 eprintln!("warning: failed to read {}: {}", path.display(), e);
96 ProjectConfig::default()
97 }
98 }
99 } else {
100 ProjectConfig::default()
101 };
102
103 if let Some(parent) = path.parent() {
105 let legacy_path = parent.join("components.toml");
106 if legacy_path.exists()
107 && let Ok(legacy_content) = std::fs::read_to_string(&legacy_path) {
108 match toml::from_str::<BTreeMap<String, ComponentConfig>>(&legacy_content) {
110 Ok(legacy_components) => {
111 let mut migrated = 0usize;
112 for (name, comp) in legacy_components {
113 config.components.entry(name).or_insert_with(|| {
115 migrated += 1;
116 comp
117 });
118 }
119 if let Err(e) = save_project_to(&config, path) {
121 eprintln!("warning: failed to save migrated config: {}", e);
122 } else {
123 if let Err(e) = std::fs::remove_file(&legacy_path) {
124 eprintln!("warning: failed to remove legacy {}: {}", legacy_path.display(), e);
125 } else {
126 eprintln!(
127 "[config] migrated {} component(s) from components.toml → config.toml",
128 migrated
129 );
130 }
131 }
132 }
133 Err(e) => {
134 eprintln!("warning: failed to parse legacy {}: {}", legacy_path.display(), e);
135 }
136 }
137 }
138 }
139
140 config
141}
142
143pub fn project_tmux_session() -> Option<String> {
145 load_project().tmux_session
146}
147
148pub fn save_project(config: &ProjectConfig) -> Result<()> {
150 save_project_to(config, &project_config_path())
151}
152
153pub(crate) fn save_project_to(config: &ProjectConfig, path: &Path) -> Result<()> {
155 if let Some(parent) = path.parent() {
156 std::fs::create_dir_all(parent)?;
157 }
158 let content = toml::to_string_pretty(config)?;
159 std::fs::write(path, content)?;
160 Ok(())
161}
162
163pub fn update_project_tmux_session(new_session: &str) -> Result<()> {
166 let mut config = load_project();
167 let old = config.tmux_session.clone();
168 config.tmux_session = Some(new_session.to_string());
169 save_project(&config)?;
170 eprintln!(
171 "[config] updated tmux_session: {} → {}",
172 old.as_deref().unwrap_or("(none)"),
173 new_session
174 );
175 Ok(())
176}
177
178fn project_config_path() -> PathBuf {
181 if let Ok(cwd) = std::env::current_dir() {
185 let mut current: &Path = &cwd;
186 loop {
187 if current.join(".agent-doc").is_dir() {
188 return current.join(".agent-doc").join("config.toml");
189 }
190 match current.parent() {
191 Some(p) => current = p,
192 None => break,
193 }
194 }
195 cwd.join(".agent-doc").join("config.toml")
197 } else {
198 PathBuf::from(".agent-doc").join("config.toml")
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use tempfile::TempDir;
206
207 fn setup_project(dir: &Path) -> PathBuf {
208 std::fs::create_dir_all(dir.join(".agent-doc")).unwrap();
209 dir.join(".agent-doc").join("config.toml")
210 }
211
212 #[test]
213 fn load_missing_config_returns_defaults() {
214 let dir = TempDir::new().unwrap();
215 let config_path = setup_project(dir.path());
216 let cfg = load_project_from(&config_path);
217 assert!(cfg.tmux_session.is_none());
218 assert!(cfg.components.is_empty());
219 }
220
221 #[test]
222 fn load_valid_config() {
223 let dir = TempDir::new().unwrap();
224 let config_path = setup_project(dir.path());
225 std::fs::write(
226 &config_path,
227 "tmux_session = \"test\"\n\n[components.exchange]\npatch = \"append\"\n",
228 )
229 .unwrap();
230 let cfg = load_project_from(&config_path);
231 assert_eq!(cfg.tmux_session.as_deref(), Some("test"));
232 assert_eq!(cfg.components["exchange"].patch, "append");
233 }
234
235 #[test]
236 fn save_and_reload_roundtrip() {
237 let dir = TempDir::new().unwrap();
238 let config_path = setup_project(dir.path());
239
240 let mut cfg = ProjectConfig::default();
241 cfg.tmux_session = Some("rt".to_string());
242 cfg.components.insert(
243 "status".to_string(),
244 ComponentConfig {
245 patch: "replace".to_string(),
246 ..Default::default()
247 },
248 );
249 save_project_to(&cfg, &config_path).unwrap();
250
251 let loaded = load_project_from(&config_path);
252 assert_eq!(loaded.tmux_session.as_deref(), Some("rt"));
253 assert_eq!(loaded.components["status"].patch, "replace");
254 }
255
256 #[test]
257 fn migrate_components_toml() {
258 let dir = TempDir::new().unwrap();
259 let config_path = setup_project(dir.path());
260
261 std::fs::write(
263 dir.path().join(".agent-doc/components.toml"),
264 "[exchange]\nmode = \"append\"\n\n[status]\nmode = \"replace\"\n",
265 )
266 .unwrap();
267
268 let cfg = load_project_from(&config_path);
269 assert_eq!(cfg.components["exchange"].patch, "append");
271 assert_eq!(cfg.components["status"].patch, "replace");
272 assert!(!dir.path().join(".agent-doc/components.toml").exists());
274 assert!(config_path.exists());
276 }
277
278 #[test]
279 fn migrate_preserves_existing_config() {
280 let dir = TempDir::new().unwrap();
281 let config_path = setup_project(dir.path());
282
283 std::fs::write(
285 &config_path,
286 "tmux_session = \"main\"\n\n[components.exchange]\npatch = \"replace\"\n",
287 )
288 .unwrap();
289 std::fs::write(
291 dir.path().join(".agent-doc/components.toml"),
292 "[exchange]\nmode = \"append\"\n\n[status]\nmode = \"replace\"\n",
293 )
294 .unwrap();
295
296 let cfg = load_project_from(&config_path);
297 assert_eq!(cfg.components["exchange"].patch, "replace");
299 assert_eq!(cfg.components["status"].patch, "replace");
301 assert_eq!(cfg.tmux_session.as_deref(), Some("main"));
303 assert!(!dir.path().join(".agent-doc/components.toml").exists());
305 }
306}