Skip to main content

agent_doc/
project_config.rs

1//! # Module: project_config
2//!
3//! Project-level configuration loaded from `.agent-doc/config.toml`.
4//! Shared between binary and library for consistent project config handling.
5//!
6//! ## Spec
7//! - Defines `ProjectConfig`: per-project settings (tmux_session, components).
8//! - Defines `ComponentConfig`: per-component patch configuration (mode, timestamps, hooks).
9//! - `load_project()` reads and parses the project config file. On absence, I/O error, or parse
10//!   error, returns `ProjectConfig::default()` and emits a warning to stderr (never panics).
11//! - `project_tmux_session()` is a convenience wrapper returning the configured tmux session name.
12//! - `save_project()` serialises `ProjectConfig` to TOML and writes it to
13//!   `.agent-doc/config.toml`, creating the directory if needed.
14//!
15//! ## Agentic Contracts
16//! - Never panics on missing config: `load_project()` returns defaults when the file is absent.
17//! - Project config errors are non-fatal: errors are surfaced as stderr warnings, not propagated.
18//! - Atomic-safe directory creation: `save_project()` calls `create_dir_all` before writing.
19
20use anyhow::Result;
21use serde::{Deserialize, Serialize};
22use std::collections::BTreeMap;
23use std::path::{Path, PathBuf};
24
25/// Component patch configuration (mode, timestamps, max entries, hooks).
26#[derive(Debug, Clone, Serialize, Deserialize, Default)]
27pub struct ComponentConfig {
28    /// Patch mode: "replace" (default), "append", "prepend".
29    /// `patch` is the primary key; `mode` is a backward-compatible alias.
30    #[serde(default = "default_patch_mode", alias = "mode")]
31    pub patch: String,
32    /// Merge strategy: "append-friendly" (default) or "strict".
33    /// "append-friendly" auto-resolves conflicts where both sides only appended.
34    /// "strict" preserves all conflict markers for manual resolution.
35    /// Currently parsed for config validation; merge runs at document level.
36    #[serde(default = "default_merge_strategy")]
37    #[allow(dead_code)]
38    pub merge_strategy: String,
39    /// Auto-prefix entries with ISO timestamp (for append/prepend modes)
40    #[serde(default)]
41    pub timestamp: bool,
42    /// Auto-trim old entries in append/prepend modes (0 = unlimited)
43    #[serde(default)]
44    pub max_entries: usize,
45    /// Trim component content to the last N lines after patching (0 = unlimited).
46    /// Currently used by template.rs post-patch processing.
47    #[serde(default)]
48    #[allow(dead_code)]
49    pub max_lines: usize,
50    /// Shell command to run before patching (stdin: content, stdout: transformed)
51    #[serde(default)]
52    pub pre_patch: Option<String>,
53    /// Shell command to run after patching (fire-and-forget)
54    #[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/// Project-level configuration, read from `.agent-doc/config.toml` relative to CWD.
67#[derive(Debug, Default, Serialize, Deserialize)]
68pub struct ProjectConfig {
69    /// Target tmux session name for this project.
70    #[serde(default)]
71    pub tmux_session: Option<String>,
72    /// Component-specific configuration (patch modes, timestamps, max_entries, hooks).
73    #[serde(default)]
74    pub components: BTreeMap<String, ComponentConfig>,
75}
76
77/// Load project config from `.agent-doc/config.toml` in CWD, or return defaults.
78/// Also performs one-time migration from legacy `components.toml` if present.
79pub fn load_project() -> ProjectConfig {
80    load_project_from(&project_config_path())
81}
82
83/// Load project config from an explicit path. Used by `load_project()` and tests.
84pub(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    // One-time migration: merge legacy components.toml into config.toml
104    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                // Legacy format: flat [name] sections with ComponentConfig fields
109                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.toml entries take precedence — only insert missing
114                            config.components.entry(name).or_insert_with(|| {
115                                migrated += 1;
116                                comp
117                            });
118                        }
119                        // Save merged config and remove legacy file
120                        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
143/// Get the project's configured tmux session (convenience helper).
144pub fn project_tmux_session() -> Option<String> {
145    load_project().tmux_session
146}
147
148/// Save project config to `.agent-doc/config.toml`.
149pub fn save_project(config: &ProjectConfig) -> Result<()> {
150    save_project_to(config, &project_config_path())
151}
152
153/// Save project config to an explicit path. Used by `save_project()` and tests.
154pub(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
163/// Update the project's configured tmux session.
164/// Called by `agent-doc session set <name>` when the user explicitly pins a session.
165pub 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
178/// Clear the project's configured tmux session, returning to auto-detect mode.
179pub 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
191/// Resolve the path to `.agent-doc/config.toml`, walking up from CWD.
192/// Exposed for testing.
193fn project_config_path() -> PathBuf {
194    // Walk up from CWD to find the .agent-doc/ project root. This avoids
195    // CWD-sensitivity when subcommands run from a subdirectory (e.g., a
196    // submodule that changed directory mid-session).
197    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        // No .agent-doc/ found walking up — fall back to CWD (uninitialized project).
209        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        // Write legacy components.toml
275        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        // Components should be migrated
283        assert_eq!(cfg.components["exchange"].patch, "append");
284        assert_eq!(cfg.components["status"].patch, "replace");
285        // Legacy file should be removed
286        assert!(!dir.path().join(".agent-doc/components.toml").exists());
287        // config.toml should exist with merged content
288        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        // Write config.toml with tmux_session and one component
297        std::fs::write(
298            &config_path,
299            "tmux_session = \"main\"\n\n[components.exchange]\npatch = \"replace\"\n",
300        )
301        .unwrap();
302        // Write legacy components.toml with exchange (append) and status (replace)
303        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        // config.toml's exchange=replace should take precedence over legacy's append
311        assert_eq!(cfg.components["exchange"].patch, "replace");
312        // status should be migrated from legacy
313        assert_eq!(cfg.components["status"].patch, "replace");
314        // tmux_session preserved
315        assert_eq!(cfg.tmux_session.as_deref(), Some("main"));
316        // Legacy file removed
317        assert!(!dir.path().join(".agent-doc/components.toml").exists());
318    }
319}