lean_ctx/core/contextops/
config.rs1use 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 let Ok(content) = std::fs::read_to_string(path) {
81 let extra = extract_user_content(&content);
82 if !extra.is_empty() {
83 agent_rules.insert(key, AgentRules { extra });
84 }
85 }
86 }
87 }
88
89 let config = RulesConfig {
90 rules: RulesSection {
91 version: default_version(),
92 core: CoreRules {
93 content: crate::rules_inject::rules_shared_content().clone(),
94 },
95 agent: agent_rules,
96 },
97 };
98
99 let path = Self::config_path(project_root);
100 if let Some(parent) = path.parent() {
101 std::fs::create_dir_all(parent)
102 .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
103 }
104 let toml_str = toml::to_string_pretty(&config)
105 .map_err(|e| format!("Failed to serialize config: {e}"))?;
106 std::fs::write(&path, &toml_str)
107 .map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
108
109 Ok(config)
110 }
111}
112
113fn extract_user_content(content: &str) -> String {
114 let start = content.find(crate::core::rules_canonical::START_MARK);
115 let end = content.find(crate::core::rules_canonical::END_MARK);
116
117 match (start, end) {
118 (Some(s), Some(e)) => {
119 let before = content[..s].trim();
120 let after_end = e + crate::core::rules_canonical::END_MARK.len();
121 let after = content[after_end..].trim();
122 let mut parts = Vec::new();
123 if !before.is_empty() {
124 parts.push(before.to_string());
125 }
126 if !after.is_empty() {
127 parts.push(after.to_string());
128 }
129 parts.join("\n\n")
130 }
131 _ => String::new(),
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn default_version_is_1_0() {
141 assert_eq!(default_version(), "1.0");
142 }
143
144 #[test]
145 fn config_path_defaults_to_new_dir() {
146 let root = PathBuf::from("/tmp/project_nonexistent_ctx_test");
147 let path = RulesConfig::config_path(&root);
148 assert_eq!(
149 path,
150 PathBuf::from("/tmp/project_nonexistent_ctx_test/.lean-ctx/rules.toml")
151 );
152 }
153
154 #[test]
155 fn config_path_falls_back_to_legacy() {
156 let dir = tempfile::tempdir().unwrap();
157 let legacy = dir.path().join(".leanctx");
158 std::fs::create_dir_all(&legacy).unwrap();
159 std::fs::write(
160 legacy.join("rules.toml"),
161 "[rules]\nversion = \"1.0\"\n[rules.core]\ncontent = \"\"",
162 )
163 .unwrap();
164
165 let path = RulesConfig::config_path(dir.path());
166 assert!(
167 path.to_string_lossy().contains(".leanctx"),
168 "should fall back to legacy .leanctx when .lean-ctx doesn't exist"
169 );
170 }
171
172 #[test]
173 fn config_path_prefers_new_over_legacy() {
174 let dir = tempfile::tempdir().unwrap();
175 let legacy = dir.path().join(".leanctx");
176 let new_dir = dir.path().join(".lean-ctx");
177 std::fs::create_dir_all(&legacy).unwrap();
178 std::fs::create_dir_all(&new_dir).unwrap();
179 std::fs::write(legacy.join("rules.toml"), "legacy").unwrap();
180 std::fs::write(new_dir.join("rules.toml"), "new").unwrap();
181
182 let path = RulesConfig::config_path(dir.path());
183 assert!(
184 path.components().any(|c| c.as_os_str() == ".lean-ctx"),
185 "should prefer .lean-ctx over legacy .leanctx when both exist; got {}",
186 path.display()
187 );
188 }
189
190 #[test]
191 fn load_missing_file_returns_error() {
192 let root = PathBuf::from("/tmp/nonexistent_contextops_test");
193 let result = RulesConfig::load(&root);
194 assert!(result.is_err());
195 assert!(result.unwrap_err().contains("No rules config found"));
196 }
197
198 #[test]
199 fn parse_minimal_config() {
200 let toml_str = r#"
201[rules]
202version = "1.0"
203
204[rules.core]
205content = "test rules"
206"#;
207 let config: RulesConfig = toml::from_str(toml_str).unwrap();
208 assert_eq!(config.rules.version, "1.0");
209 assert_eq!(config.rules.core.content, "test rules");
210 assert!(config.rules.agent.is_empty());
211 }
212
213 #[test]
214 fn parse_config_with_agents() {
215 let toml_str = r#"
216[rules]
217version = "1.0"
218
219[rules.core]
220content = "core rules"
221
222[rules.agent.cursor]
223extra = "cursor specific"
224
225[rules.agent.claude]
226extra = "claude specific"
227"#;
228 let config: RulesConfig = toml::from_str(toml_str).unwrap();
229 assert_eq!(config.rules.agent.len(), 2);
230 assert_eq!(
231 config.rules.agent.get("cursor").unwrap().extra,
232 "cursor specific"
233 );
234 }
235
236 #[test]
237 fn extract_user_content_with_markers() {
238 let content = format!(
239 "user preamble\n\n{}\nrules here\n{}\n\nuser postamble",
240 crate::core::rules_canonical::START_MARK,
241 crate::core::rules_canonical::END_MARK
242 );
243 let result = extract_user_content(&content);
244 assert!(result.contains("user preamble"));
245 assert!(result.contains("user postamble"));
246 assert!(!result.contains("rules here"));
247 }
248
249 #[test]
250 fn extract_user_content_no_markers() {
251 let result = extract_user_content("just some text");
252 assert!(result.is_empty());
253 }
254}