Skip to main content

lean_ctx/core/contextops/
config.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5const CONFIG_FILENAME: &str = "rules.toml";
6const CONFIG_DIR: &str = ".lean-ctx";
7const LEGACY_CONFIG_DIR: &str = ".leanctx";
8
9/// User-facing rules inventory persisted at `.lean-ctx/rules.toml`.
10///
11/// **Scope (read this before wiring it into anything):** `rules.toml` is the input
12/// for `rules lint` (cross-agent consistency checks) and a user-editable export
13/// produced by `rules init`. It is deliberately **not** consumed by `rules sync`
14/// or `rules diff` — those regenerate from the canonical `rules_canonical` source
15/// of truth and preserve user text around the markers. `core.content` here is a
16/// snapshot captured at `init` time for inspection/linting, never an override that
17/// `sync` would distribute (#548).
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct RulesConfig {
20    pub rules: RulesSection,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct RulesSection {
25    #[serde(default = "default_version")]
26    pub version: String,
27    #[serde(default)]
28    pub core: CoreRules,
29    #[serde(default)]
30    pub agent: std::collections::HashMap<String, AgentRules>,
31}
32
33#[derive(Debug, Clone, Default, Serialize, Deserialize)]
34pub struct CoreRules {
35    #[serde(default)]
36    pub content: String,
37}
38
39#[derive(Debug, Clone, Default, Serialize, Deserialize)]
40pub struct AgentRules {
41    #[serde(default)]
42    pub extra: String,
43}
44
45fn default_version() -> String {
46    "1.0".to_string()
47}
48
49impl RulesConfig {
50    pub fn config_path(project_root: &Path) -> PathBuf {
51        let new_path = project_root.join(CONFIG_DIR).join(CONFIG_FILENAME);
52        if new_path.exists() {
53            return new_path;
54        }
55        let legacy = project_root.join(LEGACY_CONFIG_DIR).join(CONFIG_FILENAME);
56        if legacy.exists() {
57            tracing::info!(
58                "found legacy config at {}, consider renaming {} → {}",
59                legacy.display(),
60                LEGACY_CONFIG_DIR,
61                CONFIG_DIR
62            );
63            return legacy;
64        }
65        new_path
66    }
67
68    pub fn load(project_root: &Path) -> Result<Self, String> {
69        let path = Self::config_path(project_root);
70        if !path.exists() {
71            return Err(format!(
72                "No rules config found at {}. Run `lean-ctx rules init` to create one.",
73                path.display()
74            ));
75        }
76        let content = std::fs::read_to_string(&path)
77            .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
78        toml::from_str(&content).map_err(|e| format!("Failed to parse {}: {e}", path.display()))
79    }
80
81    pub fn init_from_existing(project_root: &Path, home: &Path) -> Result<Self, String> {
82        let statuses = crate::rules_inject::collect_rules_status(home);
83
84        let mut agent_rules = std::collections::HashMap::new();
85        for status in &statuses {
86            if status.state == "up_to_date" || status.state == "outdated" {
87                let key = status.name.to_lowercase().replace(' ', "_");
88                let path = Path::new(&status.path);
89                if let Ok(content) = std::fs::read_to_string(path) {
90                    let extra = extract_user_content(&content);
91                    if !extra.is_empty() {
92                        agent_rules.insert(key, AgentRules { extra });
93                    }
94                }
95            }
96        }
97
98        let config = RulesConfig {
99            rules: RulesSection {
100                version: default_version(),
101                core: CoreRules {
102                    content: crate::rules_inject::rules_shared_content().clone(),
103                },
104                agent: agent_rules,
105            },
106        };
107
108        let path = Self::config_path(project_root);
109        if let Some(parent) = path.parent() {
110            std::fs::create_dir_all(parent)
111                .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
112        }
113        let toml_str = toml::to_string_pretty(&config)
114            .map_err(|e| format!("Failed to serialize config: {e}"))?;
115        std::fs::write(&path, &toml_str)
116            .map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
117
118        Ok(config)
119    }
120}
121
122fn extract_user_content(content: &str) -> String {
123    let start = content.find(crate::core::rules_canonical::START_MARK);
124    let end = content.find(crate::core::rules_canonical::END_MARK);
125
126    match (start, end) {
127        (Some(s), Some(e)) => {
128            let before = content[..s].trim();
129            let after_end = e + crate::core::rules_canonical::END_MARK.len();
130            let after = content[after_end..].trim();
131            let mut parts = Vec::new();
132            if !before.is_empty() {
133                parts.push(before.to_string());
134            }
135            if !after.is_empty() {
136                parts.push(after.to_string());
137            }
138            parts.join("\n\n")
139        }
140        _ => String::new(),
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn default_version_is_1_0() {
150        assert_eq!(default_version(), "1.0");
151    }
152
153    #[test]
154    fn config_path_defaults_to_new_dir() {
155        let root = PathBuf::from("/tmp/project_nonexistent_ctx_test");
156        let path = RulesConfig::config_path(&root);
157        assert_eq!(
158            path,
159            PathBuf::from("/tmp/project_nonexistent_ctx_test/.lean-ctx/rules.toml")
160        );
161    }
162
163    #[test]
164    fn config_path_falls_back_to_legacy() {
165        let dir = tempfile::tempdir().unwrap();
166        let legacy = dir.path().join(".leanctx");
167        std::fs::create_dir_all(&legacy).unwrap();
168        std::fs::write(
169            legacy.join("rules.toml"),
170            "[rules]\nversion = \"1.0\"\n[rules.core]\ncontent = \"\"",
171        )
172        .unwrap();
173
174        let path = RulesConfig::config_path(dir.path());
175        assert!(
176            path.to_string_lossy().contains(".leanctx"),
177            "should fall back to legacy .leanctx when .lean-ctx doesn't exist"
178        );
179    }
180
181    #[test]
182    fn config_path_prefers_new_over_legacy() {
183        let dir = tempfile::tempdir().unwrap();
184        let legacy = dir.path().join(".leanctx");
185        let new_dir = dir.path().join(".lean-ctx");
186        std::fs::create_dir_all(&legacy).unwrap();
187        std::fs::create_dir_all(&new_dir).unwrap();
188        std::fs::write(legacy.join("rules.toml"), "legacy").unwrap();
189        std::fs::write(new_dir.join("rules.toml"), "new").unwrap();
190
191        let path = RulesConfig::config_path(dir.path());
192        assert!(
193            path.components().any(|c| c.as_os_str() == ".lean-ctx"),
194            "should prefer .lean-ctx over legacy .leanctx when both exist; got {}",
195            path.display()
196        );
197    }
198
199    #[test]
200    fn load_missing_file_returns_error() {
201        let root = PathBuf::from("/tmp/nonexistent_contextops_test");
202        let result = RulesConfig::load(&root);
203        assert!(result.is_err());
204        assert!(result.unwrap_err().contains("No rules config found"));
205    }
206
207    #[test]
208    fn parse_minimal_config() {
209        let toml_str = r#"
210[rules]
211version = "1.0"
212
213[rules.core]
214content = "test rules"
215"#;
216        let config: RulesConfig = toml::from_str(toml_str).unwrap();
217        assert_eq!(config.rules.version, "1.0");
218        assert_eq!(config.rules.core.content, "test rules");
219        assert!(config.rules.agent.is_empty());
220    }
221
222    #[test]
223    fn parse_config_with_agents() {
224        let toml_str = r#"
225[rules]
226version = "1.0"
227
228[rules.core]
229content = "core rules"
230
231[rules.agent.cursor]
232extra = "cursor specific"
233
234[rules.agent.claude]
235extra = "claude specific"
236"#;
237        let config: RulesConfig = toml::from_str(toml_str).unwrap();
238        assert_eq!(config.rules.agent.len(), 2);
239        assert_eq!(
240            config.rules.agent.get("cursor").unwrap().extra,
241            "cursor specific"
242        );
243    }
244
245    #[test]
246    fn extract_user_content_with_markers() {
247        let content = format!(
248            "user preamble\n\n{}\nrules here\n{}\n\nuser postamble",
249            crate::core::rules_canonical::START_MARK,
250            crate::core::rules_canonical::END_MARK
251        );
252        let result = extract_user_content(&content);
253        assert!(result.contains("user preamble"));
254        assert!(result.contains("user postamble"));
255        assert!(!result.contains("rules here"));
256    }
257
258    #[test]
259    fn extract_user_content_no_markers() {
260        let result = extract_user_content("just some text");
261        assert!(result.is_empty());
262    }
263}