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