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 = ".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}