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
178pub fn clear_project_tmux_session() -> Result<()> {
180 let mut config = load_project();
181 let old = config.tmux_session.clone();
182 config.tmux_session = None;
183 save_project(&config)?;
184 eprintln!(
185 "[config] cleared tmux_session: {} → (auto-detect)",
186 old.as_deref().unwrap_or("(none)"),
187 );
188 Ok(())
189}
190
191fn project_config_path() -> PathBuf {
194 if let Ok(cwd) = std::env::current_dir() {
198 let mut current: &Path = &cwd;
199 loop {
200 if current.join(".agent-doc").is_dir() {
201 return current.join(".agent-doc").join("config.toml");
202 }
203 match current.parent() {
204 Some(p) => current = p,
205 None => break,
206 }
207 }
208 cwd.join(".agent-doc").join("config.toml")
210 } else {
211 PathBuf::from(".agent-doc").join("config.toml")
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use tempfile::TempDir;
219
220 fn setup_project(dir: &Path) -> PathBuf {
221 std::fs::create_dir_all(dir.join(".agent-doc")).unwrap();
222 dir.join(".agent-doc").join("config.toml")
223 }
224
225 #[test]
226 fn load_missing_config_returns_defaults() {
227 let dir = TempDir::new().unwrap();
228 let config_path = setup_project(dir.path());
229 let cfg = load_project_from(&config_path);
230 assert!(cfg.tmux_session.is_none());
231 assert!(cfg.components.is_empty());
232 }
233
234 #[test]
235 fn load_valid_config() {
236 let dir = TempDir::new().unwrap();
237 let config_path = setup_project(dir.path());
238 std::fs::write(
239 &config_path,
240 "tmux_session = \"test\"\n\n[components.exchange]\npatch = \"append\"\n",
241 )
242 .unwrap();
243 let cfg = load_project_from(&config_path);
244 assert_eq!(cfg.tmux_session.as_deref(), Some("test"));
245 assert_eq!(cfg.components["exchange"].patch, "append");
246 }
247
248 #[test]
249 fn save_and_reload_roundtrip() {
250 let dir = TempDir::new().unwrap();
251 let config_path = setup_project(dir.path());
252
253 let mut cfg = ProjectConfig::default();
254 cfg.tmux_session = Some("rt".to_string());
255 cfg.components.insert(
256 "status".to_string(),
257 ComponentConfig {
258 patch: "replace".to_string(),
259 ..Default::default()
260 },
261 );
262 save_project_to(&cfg, &config_path).unwrap();
263
264 let loaded = load_project_from(&config_path);
265 assert_eq!(loaded.tmux_session.as_deref(), Some("rt"));
266 assert_eq!(loaded.components["status"].patch, "replace");
267 }
268
269 #[test]
270 fn migrate_components_toml() {
271 let dir = TempDir::new().unwrap();
272 let config_path = setup_project(dir.path());
273
274 std::fs::write(
276 dir.path().join(".agent-doc/components.toml"),
277 "[exchange]\nmode = \"append\"\n\n[status]\nmode = \"replace\"\n",
278 )
279 .unwrap();
280
281 let cfg = load_project_from(&config_path);
282 assert_eq!(cfg.components["exchange"].patch, "append");
284 assert_eq!(cfg.components["status"].patch, "replace");
285 assert!(!dir.path().join(".agent-doc/components.toml").exists());
287 assert!(config_path.exists());
289 }
290
291 #[test]
292 fn migrate_preserves_existing_config() {
293 let dir = TempDir::new().unwrap();
294 let config_path = setup_project(dir.path());
295
296 std::fs::write(
298 &config_path,
299 "tmux_session = \"main\"\n\n[components.exchange]\npatch = \"replace\"\n",
300 )
301 .unwrap();
302 std::fs::write(
304 dir.path().join(".agent-doc/components.toml"),
305 "[exchange]\nmode = \"append\"\n\n[status]\nmode = \"replace\"\n",
306 )
307 .unwrap();
308
309 let cfg = load_project_from(&config_path);
310 assert_eq!(cfg.components["exchange"].patch, "replace");
312 assert_eq!(cfg.components["status"].patch, "replace");
314 assert_eq!(cfg.tmux_session.as_deref(), Some("main"));
316 assert!(!dir.path().join(".agent-doc/components.toml").exists());
318 }
319}