Skip to main content

lean_ctx/kits/
mod.rs

1//! Context Kits are versioned, task-specific context plans.
2//!
3//! Kits complement the general-purpose context profiles: a profile controls
4//! broad runtime defaults, while a kit supplies the ordered read plan, shell
5//! evidence, review bar, and prompt for one concrete workflow.
6
7use 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/// A fully parsed Context Kit.
20#[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/// Stable identity and display metadata for a kit.
33#[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/// Ordered read rules. The first matching rule wins.
42#[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/// A glob and its recommended read stages.
52#[derive(Debug, Clone, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct ReadRule {
55    pub glob: String,
56    pub modes: Vec<String>,
57}
58
59/// What must receive attention first.
60#[derive(Debug, Clone, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct PriorityPlan {
63    pub strategy: String,
64    pub description: String,
65}
66
67/// Shell output relevant to the kit.
68#[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/// One command and the evidence retained by its compressor.
77#[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/// Review-output quality bar.
86#[derive(Debug, Clone, Deserialize)]
87#[serde(deny_unknown_fields)]
88pub struct QualityPlan {
89    pub criteria: Vec<String>,
90}
91
92/// System instruction supplied to the workflow LLM.
93#[derive(Debug, Clone, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct SystemPrompt {
96    pub template: String,
97}
98
99/// Where a kit was loaded from.
100#[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/// A kit together with the location that supplied it.
118#[derive(Debug, Clone)]
119pub struct LoadedKit {
120    pub kit: ContextKit,
121    pub source: KitSource,
122}
123
124/// Errors returned while locating or validating a kit.
125#[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    /// Returns the ordered read stages for `path`; the first matching rule wins.
144    #[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    /// Returns the first automatic mode for `path`.
157    ///
158    /// Multi-stage plans are intentionally retained by [`Self::modes_for_path`]
159    /// for agents that request the subsequent, more detailed stage explicitly.
160    #[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
166/// Loads a named kit from the project, user directory, or built-in catalogue.
167pub 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/// Loads the configured kit, if one has been selected.
190#[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/// Returns the kit-selected automatic read mode for `path`.
201#[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/// Lists the names in the built-in catalogue plus discoverable local kits.
207#[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
240/// Parses and validates a Context Kit document without loading or activating it.
241pub 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
370/// Matches a kit rule using the project's standard glob implementation.
371fn 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}