skilllite_evolution/seed/
mod.rs1use std::path::{Path, PathBuf};
4
5use skilllite_core::planning::{PlanningRule, SourceRegistry};
6
7const SEED_VERSION: u32 = 1;
8
9const SEED_RULES: &str = include_str!("rules.seed.json");
10const SEED_SOURCES: &str = include_str!("sources.seed.json");
11const SEED_SYSTEM: &str = include_str!("system.seed.md");
12const SEED_PLANNING: &str = include_str!("planning.seed.md");
13const SEED_EXECUTION: &str = include_str!("execution.seed.md");
14const SEED_EXAMPLES: &str = include_str!("examples.seed.md");
15
16fn prompts_dir(chat_root: &Path) -> PathBuf {
17 chat_root.join("prompts")
18}
19
20pub fn ensure_seed_data(chat_root: &Path) {
21 let dir = prompts_dir(chat_root);
22 let version_file = dir.join(".seed_version");
23
24 let current_version = std::fs::read_to_string(&version_file)
25 .ok()
26 .and_then(|s| s.trim().parse::<u32>().ok())
27 .unwrap_or(0);
28
29 if current_version >= SEED_VERSION {
30 return;
31 }
32
33 if std::fs::create_dir_all(&dir).is_err() {
34 tracing::warn!("Failed to create prompts dir: {}", dir.display());
35 return;
36 }
37
38 let rules_exist = dir.join("rules.json").exists();
39 if !rules_exist {
40 write_seed_file(&dir, "rules.json", SEED_RULES);
41 write_seed_file(&dir, "sources.json", SEED_SOURCES);
42 write_seed_file(&dir, "system.md", SEED_SYSTEM);
43 write_seed_file(&dir, "planning.md", SEED_PLANNING);
44 write_seed_file(&dir, "execution.md", SEED_EXECUTION);
45 write_seed_file(&dir, "examples.md", SEED_EXAMPLES);
46 } else {
47 merge_seed_rules(&dir);
48 merge_seed_sources(&dir);
49 write_if_unchanged(&dir, "system.md", SEED_SYSTEM);
50 write_if_unchanged(&dir, "planning.md", SEED_PLANNING);
51 write_if_unchanged(&dir, "execution.md", SEED_EXECUTION);
52 write_if_unchanged(&dir, "examples.md", SEED_EXAMPLES);
53 }
54
55 let _ = std::fs::write(&version_file, SEED_VERSION.to_string());
56 tracing::info!("Seed data v{} written to {}", SEED_VERSION, dir.display());
57}
58
59pub fn ensure_seed_data_force(chat_root: &Path) {
60 let dir = prompts_dir(chat_root);
61 if std::fs::create_dir_all(&dir).is_err() {
62 tracing::warn!("Failed to create prompts dir: {}", dir.display());
63 return;
64 }
65 write_seed_file(&dir, "rules.json", SEED_RULES);
66 write_seed_file(&dir, "sources.json", SEED_SOURCES);
67 write_seed_file(&dir, "system.md", SEED_SYSTEM);
68 write_seed_file(&dir, "planning.md", SEED_PLANNING);
69 write_seed_file(&dir, "execution.md", SEED_EXECUTION);
70 write_seed_file(&dir, "examples.md", SEED_EXAMPLES);
71 let _ = std::fs::write(dir.join(".seed_version"), SEED_VERSION.to_string());
72 tracing::info!("Seed data force-reset to v{}", SEED_VERSION);
73}
74
75fn write_seed_file(dir: &Path, name: &str, content: &str) {
76 let path = dir.join(name);
77 if let Err(e) = std::fs::write(&path, content) {
78 tracing::warn!("Failed to write seed file {}: {}", path.display(), e);
79 }
80}
81
82fn write_if_unchanged(dir: &Path, name: &str, new_content: &str) {
83 let path = dir.join(name);
84 if !path.exists() {
85 write_seed_file(dir, name, new_content);
86 return;
87 }
88 if let Ok(existing) = std::fs::read_to_string(&path) {
89 if existing.trim() == new_content.trim() {
90 return;
91 }
92 }
93 write_seed_file(dir, name, new_content);
94}
95
96fn merge_seed_rules(dir: &Path) {
97 let rules_path = dir.join("rules.json");
98 let existing: Vec<PlanningRule> = if rules_path.exists() {
99 std::fs::read_to_string(&rules_path)
100 .ok()
101 .and_then(|s| serde_json::from_str(&s).ok())
102 .unwrap_or_default()
103 } else {
104 Vec::new()
105 };
106
107 let seed: Vec<PlanningRule> = serde_json::from_str(SEED_RULES).unwrap_or_default();
108
109 let mut merged = existing.clone();
110 for seed_rule in &seed {
111 let exists = merged.iter().any(|r| r.id == seed_rule.id);
112 if !exists {
113 merged.push(seed_rule.clone());
114 }
115 if let Some(existing_rule) = merged
116 .iter_mut()
117 .find(|r| r.id == seed_rule.id && !r.mutable)
118 {
119 *existing_rule = seed_rule.clone();
120 }
121 }
122
123 if let Ok(json) = serde_json::to_string_pretty(&merged) {
124 write_seed_file(dir, "rules.json", &json);
125 }
126}
127
128fn merge_seed_sources(dir: &Path) {
129 let sources_path = dir.join("sources.json");
130 let seed_registry: SourceRegistry = match serde_json::from_str(SEED_SOURCES) {
131 Ok(r) => r,
132 Err(e) => {
133 tracing::warn!("Failed to parse SEED_SOURCES: {}", e);
134 return;
135 }
136 };
137
138 let mut existing_registry: SourceRegistry = if sources_path.exists() {
139 std::fs::read_to_string(&sources_path)
140 .ok()
141 .and_then(|s| serde_json::from_str(&s).ok())
142 .unwrap_or_else(|| SourceRegistry {
143 version: 1,
144 sources: Vec::new(),
145 })
146 } else {
147 SourceRegistry {
148 version: 1,
149 sources: Vec::new(),
150 }
151 };
152
153 for seed_src in &seed_registry.sources {
154 let already_exists = existing_registry
155 .sources
156 .iter()
157 .any(|s| s.id == seed_src.id);
158 if !already_exists {
159 existing_registry.sources.push(seed_src.clone());
160 }
161 if let Some(existing) = existing_registry
162 .sources
163 .iter_mut()
164 .find(|s| s.id == seed_src.id && !s.mutable)
165 {
166 existing.name = seed_src.name.clone();
167 existing.url = seed_src.url.clone();
168 existing.source_type = seed_src.source_type.clone();
169 existing.parser = seed_src.parser.clone();
170 existing.region = seed_src.region.clone();
171 existing.language = seed_src.language.clone();
172 existing.domains = seed_src.domains.clone();
173 }
174 }
175
176 if let Ok(json) = serde_json::to_string_pretty(&existing_registry) {
177 write_seed_file(dir, "sources.json", &json);
178 }
179}
180
181pub fn load_rules(chat_root: &Path) -> Vec<PlanningRule> {
182 let path = prompts_dir(chat_root).join("rules.json");
183 if path.exists() {
184 if let Ok(content) = std::fs::read_to_string(&path) {
185 if let Ok(rules) = serde_json::from_str::<Vec<PlanningRule>>(&content) {
186 if !rules.is_empty() {
187 return rules;
188 }
189 }
190 }
191 }
192 serde_json::from_str(SEED_RULES).unwrap_or_default()
193}
194
195pub fn load_sources(chat_root: &Path) -> SourceRegistry {
196 let path = prompts_dir(chat_root).join("sources.json");
197 if path.exists() {
198 if let Ok(content) = std::fs::read_to_string(&path) {
199 if let Ok(registry) = serde_json::from_str::<SourceRegistry>(&content) {
200 if !registry.sources.is_empty() {
201 return registry;
202 }
203 }
204 }
205 }
206 serde_json::from_str(SEED_SOURCES).unwrap_or_else(|_| SourceRegistry {
207 version: 1,
208 sources: Vec::new(),
209 })
210}
211
212pub fn load_system_prompt(chat_root: &Path) -> String {
213 load_prompt_file(chat_root, "system.md", SEED_SYSTEM)
214}
215
216pub fn load_planning_template(chat_root: &Path) -> String {
217 load_prompt_file(chat_root, "planning.md", SEED_PLANNING)
218}
219
220pub fn load_execution_template(chat_root: &Path) -> String {
221 load_prompt_file(chat_root, "execution.md", SEED_EXECUTION)
222}
223
224pub fn load_examples(chat_root: &Path) -> String {
225 load_prompt_file(chat_root, "examples.md", SEED_EXAMPLES)
226}
227
228pub fn required_placeholders(name: &str) -> &'static [&'static str] {
229 match name {
230 "planning.md" => &[
231 "{{TODAY}}",
232 "{{RULES_SECTION}}",
233 "{{EXAMPLES_SECTION}}",
234 "{{OUTPUT_DIR}}",
235 ],
236 "execution.md" => &["{{TODAY}}", "{{SKILLS_LIST}}", "{{OUTPUT_DIR}}"],
237 "system.md" => &[],
238 "examples.md" => &[],
239 _ => &[],
240 }
241}
242
243pub fn validate_template(name: &str, content: &str) -> Vec<&'static str> {
244 required_placeholders(name)
245 .iter()
246 .filter(|p| !content.contains(**p))
247 .copied()
248 .collect()
249}
250
251pub fn load_prompt_file_with_project(
252 chat_root: &Path,
253 workspace: Option<&Path>,
254 name: &str,
255 fallback: &str,
256) -> String {
257 if let Some(ws) = workspace {
258 let project_path = ws.join(".skilllite").join("prompts").join(name);
259 if project_path.exists() {
260 if let Ok(content) = std::fs::read_to_string(&project_path) {
261 if !content.trim().is_empty() {
262 let missing = validate_template(name, &content);
263 if !missing.is_empty() {
264 tracing::warn!(
265 "Project template {} is missing placeholders {:?}",
266 project_path.display(),
267 missing
268 );
269 }
270 return content;
271 }
272 }
273 }
274 }
275 load_prompt_file(chat_root, name, fallback)
276}
277
278fn load_prompt_file(chat_root: &Path, name: &str, fallback: &str) -> String {
279 let path = prompts_dir(chat_root).join(name);
280 if path.exists() {
281 if let Ok(content) = std::fs::read_to_string(&path) {
282 if !content.trim().is_empty() {
283 let missing = validate_template(name, &content);
284 if !missing.is_empty() {
285 tracing::warn!(
286 "Template {} is missing placeholders {:?}",
287 path.display(),
288 missing
289 );
290 }
291 return content;
292 }
293 }
294 }
295 fallback.to_string()
296}
297
298#[cfg(test)]
299mod template_tests {
300 use super::{required_placeholders, validate_template};
301
302 #[test]
303 fn required_placeholders_planning_lists_four() {
304 let p = required_placeholders("planning.md");
305 assert_eq!(p.len(), 4);
306 assert!(p.contains(&"{{RULES_SECTION}}"));
307 }
308
309 #[test]
310 fn validate_template_reports_missing_placeholders() {
311 let missing = validate_template("planning.md", "no placeholders");
312 assert!(!missing.is_empty());
313 assert!(missing.contains(&"{{TODAY}}"));
314 let ok = validate_template(
315 "planning.md",
316 "{{TODAY}}{{RULES_SECTION}}{{EXAMPLES_SECTION}}{{OUTPUT_DIR}}",
317 );
318 assert!(ok.is_empty());
319 }
320
321 #[test]
322 fn validate_template_unknown_name_is_permissive() {
323 assert!(validate_template("other.md", "").is_empty());
324 }
325}