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 when the configured session is dead and we fall back to a different one.
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/// Resolve the path to `.agent-doc/config.toml`, walking up from CWD.
179/// Exposed for testing.
180fn project_config_path() -> PathBuf {
181    // Walk up from CWD to find the .agent-doc/ project root. This avoids
182    // CWD-sensitivity when subcommands run from a subdirectory (e.g., a
183    // submodule that changed directory mid-session).
184    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        // No .agent-doc/ found walking up — fall back to CWD (uninitialized project).
196        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        // Write legacy components.toml
262        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        // Components should be migrated
270        assert_eq!(cfg.components["exchange"].patch, "append");
271        assert_eq!(cfg.components["status"].patch, "replace");
272        // Legacy file should be removed
273        assert!(!dir.path().join(".agent-doc/components.toml").exists());
274        // config.toml should exist with merged content
275        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        // Write config.toml with tmux_session and one component
284        std::fs::write(
285            &config_path,
286            "tmux_session = \"main\"\n\n[components.exchange]\npatch = \"replace\"\n",
287        )
288        .unwrap();
289        // Write legacy components.toml with exchange (append) and status (replace)
290        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        // config.toml's exchange=replace should take precedence over legacy's append
298        assert_eq!(cfg.components["exchange"].patch, "replace");
299        // status should be migrated from legacy
300        assert_eq!(cfg.components["status"].patch, "replace");
301        // tmux_session preserved
302        assert_eq!(cfg.tmux_session.as_deref(), Some("main"));
303        // Legacy file removed
304        assert!(!dir.path().join(".agent-doc/components.toml").exists());
305    }
306}