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