1use std::collections::HashSet;
8use std::fmt;
9use std::path::{Path, PathBuf};
10use std::str::FromStr;
11
12use serde::Deserialize;
13
14const CODE_REVIEW_KIT: &str = include_str!(concat!(
15 env!("CARGO_MANIFEST_DIR"),
16 "/assets/kits/code-review/kit.toml"
17));
18
19#[derive(Debug, Clone, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct ContextKit {
23 pub format_version: u32,
24 pub kit: KitMetadata,
25 pub read: ReadPlan,
26 pub priority: PriorityPlan,
27 pub shell: ShellPlan,
28 pub quality: QualityPlan,
29 pub system_prompt: SystemPrompt,
30}
31
32#[derive(Debug, Clone, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct KitMetadata {
36 pub name: String,
37 pub description: String,
38 pub version: String,
39}
40
41#[derive(Debug, Clone, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct ReadPlan {
45 #[serde(default)]
46 pub default_modes: Vec<String>,
47 #[serde(default)]
48 pub rules: Vec<ReadRule>,
49}
50
51#[derive(Debug, Clone, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct ReadRule {
55 pub glob: String,
56 pub modes: Vec<String>,
57}
58
59#[derive(Debug, Clone, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct PriorityPlan {
63 pub strategy: String,
64 pub description: String,
65}
66
67#[derive(Debug, Clone, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct ShellPlan {
71 pub default_mode: String,
72 #[serde(default)]
73 pub commands: Vec<ShellCommand>,
74}
75
76#[derive(Debug, Clone, Deserialize)]
78#[serde(deny_unknown_fields)]
79pub struct ShellCommand {
80 pub command: String,
81 #[serde(default)]
82 pub focus: Vec<String>,
83}
84
85#[derive(Debug, Clone, Deserialize)]
87#[serde(deny_unknown_fields)]
88pub struct QualityPlan {
89 pub criteria: Vec<String>,
90}
91
92#[derive(Debug, Clone, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct SystemPrompt {
96 pub template: String,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum KitSource {
102 Project(PathBuf),
103 User(PathBuf),
104 Builtin,
105}
106
107impl fmt::Display for KitSource {
108 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 match self {
110 Self::Project(path) => write!(f, "project ({})", path.display()),
111 Self::User(path) => write!(f, "user ({})", path.display()),
112 Self::Builtin => write!(f, "built-in"),
113 }
114 }
115}
116
117#[derive(Debug, Clone)]
119pub struct LoadedKit {
120 pub kit: ContextKit,
121 pub source: KitSource,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct KitError(String);
127
128impl KitError {
129 fn new(message: impl Into<String>) -> Self {
130 Self(message.into())
131 }
132}
133
134impl fmt::Display for KitError {
135 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136 f.write_str(&self.0)
137 }
138}
139
140impl std::error::Error for KitError {}
141
142impl ContextKit {
143 #[must_use]
145 pub fn modes_for_path(&self, path: impl AsRef<Path>) -> &[String] {
146 let path = path.as_ref().to_string_lossy();
147 self.read
148 .rules
149 .iter()
150 .find(|rule| glob_matches(&rule.glob, &path))
151 .map_or(self.read.default_modes.as_slice(), |rule| {
152 rule.modes.as_slice()
153 })
154 }
155
156 #[must_use]
161 pub fn preferred_mode_for_path(&self, path: impl AsRef<Path>) -> Option<&str> {
162 self.modes_for_path(path).first().map(String::as_str)
163 }
164}
165
166pub fn load(name: &str) -> Result<LoadedKit, KitError> {
168 validate_name(name)?;
169
170 for path in project_kit_paths(name) {
171 if path.is_file() {
172 return load_from_path(&path, KitSource::Project(path.clone()));
173 }
174 }
175
176 if let Some(path) = user_kit_path(name)
177 && path.is_file()
178 {
179 return load_from_path(&path, KitSource::User(path.clone()));
180 }
181
182 if name == "code-review" {
183 return parse(CODE_REVIEW_KIT, KitSource::Builtin);
184 }
185
186 Err(KitError::new(format!("kit '{name}' not found")))
187}
188
189#[must_use]
191pub fn active() -> Option<LoadedKit> {
192 let name = std::env::var("LEAN_CTX_KIT")
193 .ok()
194 .map(|value| value.trim().to_string())
195 .filter(|value| !value.is_empty())
196 .or_else(|| crate::core::config::Config::load().active_kit);
197 name.and_then(|name| load(&name).ok())
198}
199
200#[must_use]
202pub fn active_mode_for_path(path: &str) -> Option<String> {
203 active().and_then(|loaded| loaded.kit.preferred_mode_for_path(path).map(str::to_string))
204}
205
206#[must_use]
208pub fn list() -> Vec<String> {
209 let mut names = HashSet::from(["code-review".to_string()]);
210
211 for root in project_kit_roots()
212 .into_iter()
213 .chain(user_kit_root().into_iter())
214 {
215 let Ok(entries) = std::fs::read_dir(root) else {
216 continue;
217 };
218 for entry in entries.flatten() {
219 let path = entry.path();
220 if path.join("kit.toml").is_file()
221 && let Some(name) = path.file_name().and_then(|name| name.to_str())
222 && validate_name(name).is_ok()
223 {
224 names.insert(name.to_string());
225 }
226 }
227 }
228
229 let mut names: Vec<_> = names.into_iter().collect();
230 names.sort();
231 names
232}
233
234fn load_from_path(path: &Path, source: KitSource) -> Result<LoadedKit, KitError> {
235 let content = std::fs::read_to_string(path)
236 .map_err(|error| KitError::new(format!("cannot read {}: {error}", path.display())))?;
237 parse(&content, source)
238}
239
240pub fn parse_document(content: &str) -> Result<ContextKit, KitError> {
242 let kit = toml::from_str::<ContextKit>(content)
243 .map_err(|error| KitError::new(format!("invalid kit TOML: {error}")))?;
244 validate(&kit)?;
245 Ok(kit)
246}
247
248fn parse(content: &str, source: KitSource) -> Result<LoadedKit, KitError> {
249 let kit = parse_document(content)?;
250 Ok(LoadedKit { kit, source })
251}
252
253fn validate(kit: &ContextKit) -> Result<(), KitError> {
254 if kit.format_version != 1 {
255 return Err(KitError::new(format!(
256 "unsupported kit format version {}; expected 1",
257 kit.format_version
258 )));
259 }
260 validate_name(&kit.kit.name)?;
261 if kit.kit.description.trim().is_empty() || kit.kit.version.trim().is_empty() {
262 return Err(KitError::new(
263 "kit description and version must not be empty",
264 ));
265 }
266 validate_modes(&kit.read.default_modes, "read.default_modes")?;
267 for rule in &kit.read.rules {
268 if rule.glob.trim().is_empty() {
269 return Err(KitError::new("read rule glob must not be empty"));
270 }
271 validate_modes(&rule.modes, &format!("read rule '{}'.modes", rule.glob))?;
272 }
273 if kit.priority.strategy != "git_changed" {
274 return Err(KitError::new(
275 "priority.strategy must be 'git_changed' for the current kit format",
276 ));
277 }
278 if kit.priority.description.trim().is_empty() {
279 return Err(KitError::new("priority.description must not be empty"));
280 }
281 if kit.shell.default_mode != "compressed" {
282 return Err(KitError::new(
283 "shell.default_mode must be 'compressed' for the current kit format",
284 ));
285 }
286 if kit
287 .shell
288 .commands
289 .iter()
290 .any(|command| command.command.trim().is_empty())
291 {
292 return Err(KitError::new("shell command must not be empty"));
293 }
294 if kit.quality.criteria.is_empty()
295 || kit
296 .quality
297 .criteria
298 .iter()
299 .any(|criterion| criterion.trim().is_empty())
300 {
301 return Err(KitError::new(
302 "quality.criteria must contain non-empty criteria",
303 ));
304 }
305 if kit.system_prompt.template.trim().is_empty() {
306 return Err(KitError::new("system_prompt.template must not be empty"));
307 }
308 Ok(())
309}
310
311fn validate_modes(modes: &[String], field: &str) -> Result<(), KitError> {
312 if modes.is_empty() {
313 return Err(KitError::new(format!("{field} must not be empty")));
314 }
315 for mode in modes {
316 crate::core::read_mode::ReadMode::from_str(mode).map_err(|error| {
317 KitError::new(format!(
318 "{field} contains unsupported mode '{mode}': {error}"
319 ))
320 })?;
321 }
322 Ok(())
323}
324
325fn validate_name(name: &str) -> Result<(), KitError> {
326 if name.is_empty()
327 || !name
328 .bytes()
329 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
330 {
331 return Err(KitError::new(
332 "kit name must contain only ASCII letters, digits, '-' or '_'",
333 ));
334 }
335 Ok(())
336}
337
338fn project_kit_paths(name: &str) -> Vec<PathBuf> {
339 project_kit_roots()
340 .into_iter()
341 .map(|root| root.join(name).join("kit.toml"))
342 .collect()
343}
344
345fn project_kit_roots() -> Vec<PathBuf> {
346 let mut roots = Vec::new();
347 let Ok(mut current) = std::env::current_dir() else {
348 return roots;
349 };
350 for _ in 0..12 {
351 roots.push(current.join("kits"));
352 roots.push(current.join(".lean-ctx").join("kits"));
353 if !current.pop() {
354 break;
355 }
356 }
357 roots
358}
359
360fn user_kit_root() -> Option<PathBuf> {
361 crate::core::data_dir::lean_ctx_data_dir()
362 .ok()
363 .map(|directory| directory.join("kits"))
364}
365
366fn user_kit_path(name: &str) -> Option<PathBuf> {
367 user_kit_root().map(|root| root.join(name).join("kit.toml"))
368}
369
370fn glob_matches(pattern: &str, path: &str) -> bool {
372 glob::Pattern::new(pattern).is_ok_and(|pattern| pattern.matches_path(Path::new(path)))
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 #[test]
380 fn code_review_kit_has_the_requested_read_plan() {
381 let loaded = load("code-review").expect("built-in kit loads");
382 let kit = loaded.kit;
383
384 assert_eq!(kit.modes_for_path("src/lib.rs"), ["map", "signatures"]);
385 assert_eq!(kit.modes_for_path("tests/review.rs"), ["signatures"]);
386 assert_eq!(kit.modes_for_path("Cargo.toml"), ["full"]);
387 assert_eq!(kit.priority.strategy, "git_changed");
388 assert!(
389 kit.shell
390 .commands
391 .iter()
392 .any(|command| command.command == "cargo test")
393 );
394 assert!(
395 kit.shell
396 .commands
397 .iter()
398 .any(|command| command.command == "cargo clippy")
399 );
400 assert!(
401 !kit.quality.criteria.is_empty(),
402 "`kit.quality.criteria` is empty"
403 );
404 assert!(kit.system_prompt.template.contains("actionable findings"));
405 }
406
407 #[test]
408 fn kit_rejects_unknown_read_mode() {
409 let invalid = CODE_REVIEW_KIT.replacen("modes = [\"full\"]", "modes = [\"outline\"]", 1);
410 let error = parse(&invalid, KitSource::Builtin).expect_err("mode must be valid");
411 assert!(error.to_string().contains("unsupported mode 'outline'"));
412 }
413
414 #[test]
415 fn document_parser_preserves_builtin_contract_and_first_match() {
416 let kit = parse_document(CODE_REVIEW_KIT).expect("built-in document parses");
417
418 assert_eq!(kit.kit.name, "code-review");
419 assert_eq!(kit.modes_for_path("src/tests/review.rs"), ["signatures"]);
420 assert_eq!(kit.modes_for_path("src/lib.rs"), ["map", "signatures"]);
421 }
422
423 #[test]
424 fn document_parser_rejects_unknown_fields_and_invalid_values_deterministically() {
425 let cases = [
426 (
427 format!("{CODE_REVIEW_KIT}\nunexpected = true\n"),
428 "unknown field",
429 ),
430 (
431 CODE_REVIEW_KIT.replacen("format_version = 1", "format_version = 2", 1),
432 "unsupported kit format version 2",
433 ),
434 (
435 CODE_REVIEW_KIT.replacen("name = \"code-review\"", "name = \"../code-review\"", 1),
436 "kit name must contain only ASCII",
437 ),
438 (
439 CODE_REVIEW_KIT.replacen(
440 "default_modes = [\"map\", \"signatures\"]",
441 "default_modes = []",
442 1,
443 ),
444 "read.default_modes must not be empty",
445 ),
446 (
447 CODE_REVIEW_KIT.replacen("command = \"cargo test\"", "command = \" \"", 1),
448 "shell command must not be empty",
449 ),
450 (
451 CODE_REVIEW_KIT.replacen(
452 "\"Report only actionable defects introduced or exposed by the change.\"",
453 "\"\"",
454 1,
455 ),
456 "quality.criteria must contain non-empty criteria",
457 ),
458 ];
459
460 for (document, expected) in cases {
461 let first = parse_document(&document).expect_err("document must be rejected");
462 let second = parse_document(&document).expect_err("repeated parse must be rejected");
463 assert_eq!(first, second);
464 assert!(first.to_string().contains(expected), "{first}");
465 }
466
467 let mut empty_template = CODE_REVIEW_KIT.to_string();
468 let prefix = "template = '''";
469 let start = empty_template.find(prefix).expect("template starts") + prefix.len();
470 let end = start + empty_template[start..].find("'''").expect("template ends");
471 empty_template.replace_range(start..end, " ");
472 let error = parse_document(&empty_template).expect_err("template must not be empty");
473 assert_eq!(
474 error.to_string(),
475 "system_prompt.template must not be empty"
476 );
477 }
478
479 #[test]
480 fn document_parser_never_executes_declared_commands() {
481 let directory = tempfile::tempdir().expect("temporary directory");
482 let marker = directory.path().join("executed");
483 let marker_toml = marker
484 .to_string_lossy()
485 .replace('\\', "\\\\")
486 .replace('"', "\\\"");
487 let command = format!("command = \"touch {marker_toml}\"");
488 let document = CODE_REVIEW_KIT.replacen("command = \"cargo test\"", &command, 1);
489
490 parse_document(&document).expect("command remains inert data");
491
492 assert!(!marker.exists());
493 }
494
495 #[test]
496 fn glob_matching_honors_path_segments() {
497 assert!(glob_matches("**/tests/**/*.rs", "tests/unit/review.rs"));
498 assert!(glob_matches("**/tests/**/*.rs", "src/tests/review.rs"));
499 assert!(!glob_matches("**/tests/**/*.rs", "src/test_helpers.rs"));
500 assert!(glob_matches("**/*_test.rs", "src/parser_test.rs"));
501 }
502
503 #[test]
504 fn kit_name_cannot_escape_its_directory() {
505 assert!(load("../code-review").is_err());
506 }
507}