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)]
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}