Skip to main content

lean_ctx/core/
protocol.rs

1use std::path::Path;
2
3/// Finds the outermost project root by walking up from `file_path`.
4/// For monorepos with nested `.git` dirs (e.g. `mono/backend/.git` + `mono/frontend/.git`),
5/// returns the outermost ancestor containing `.git`, a workspace marker, or a known
6/// monorepo config file — so the whole monorepo is treated as one project.
7pub fn detect_project_root(file_path: &str) -> Option<String> {
8    let mut dir = Path::new(file_path).parent()?;
9    let mut best: Option<String> = None;
10
11    loop {
12        if is_project_root_marker(dir) {
13            best = Some(dir.to_string_lossy().to_string());
14        }
15        match dir.parent() {
16            Some(parent) if parent != dir => dir = parent,
17            _ => break,
18        }
19    }
20    best
21}
22
23/// Checks if a directory looks like a project root (has `.git`, workspace config, etc.).
24fn is_project_root_marker(dir: &Path) -> bool {
25    const MARKERS: &[&str] = &[
26        ".git",
27        "Cargo.toml",
28        "package.json",
29        "go.work",
30        "pnpm-workspace.yaml",
31        "lerna.json",
32        "nx.json",
33        "turbo.json",
34        ".projectile",
35        "pyproject.toml",
36        "setup.py",
37        "Makefile",
38        "CMakeLists.txt",
39        "BUILD.bazel",
40    ];
41    MARKERS.iter().any(|m| dir.join(m).exists())
42}
43
44pub fn detect_project_root_or_cwd(file_path: &str) -> String {
45    detect_project_root(file_path).unwrap_or_else(|| {
46        std::env::current_dir()
47            .map(|p| p.to_string_lossy().to_string())
48            .unwrap_or_else(|_| ".".to_string())
49    })
50}
51
52pub fn shorten_path(path: &str) -> String {
53    let p = Path::new(path);
54    if let Some(name) = p.file_name() {
55        return name.to_string_lossy().to_string();
56    }
57    path.to_string()
58}
59
60pub fn format_savings(original: usize, compressed: usize) -> String {
61    let saved = original.saturating_sub(compressed);
62    if original == 0 {
63        return "0 tok saved".to_string();
64    }
65    let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
66    format!("[{saved} tok saved ({pct}%)]")
67}
68
69pub struct InstructionTemplate {
70    pub code: &'static str,
71    pub full: &'static str,
72}
73
74const TEMPLATES: &[InstructionTemplate] = &[
75    InstructionTemplate {
76        code: "ACT1",
77        full: "Act immediately, report result in one line",
78    },
79    InstructionTemplate {
80        code: "BRIEF",
81        full: "Summarize approach in 1-2 lines, then act",
82    },
83    InstructionTemplate {
84        code: "FULL",
85        full: "Outline approach, consider edge cases, then act",
86    },
87    InstructionTemplate {
88        code: "DELTA",
89        full: "Only show changed lines, not full files",
90    },
91    InstructionTemplate {
92        code: "NOREPEAT",
93        full: "Never repeat known context. Reference cached files by Fn ID",
94    },
95    InstructionTemplate {
96        code: "STRUCT",
97        full: "Use notation, not sentences. Changes: +line/-line/~line",
98    },
99    InstructionTemplate {
100        code: "1LINE",
101        full: "One line per action. Summarize, don't explain",
102    },
103    InstructionTemplate {
104        code: "NODOC",
105        full: "Don't add comments that narrate what code does",
106    },
107    InstructionTemplate {
108        code: "ACTFIRST",
109        full: "Execute tool calls immediately. Never narrate before acting",
110    },
111    InstructionTemplate {
112        code: "QUALITY",
113        full: "Never skip edge case analysis or error handling to save tokens",
114    },
115    InstructionTemplate {
116        code: "NOMOCK",
117        full: "Never use mock data, fake values, or placeholder code",
118    },
119    InstructionTemplate {
120        code: "FREF",
121        full: "Reference files by Fn refs only, never full paths",
122    },
123    InstructionTemplate {
124        code: "DIFF",
125        full: "For code changes: show only diff lines, not full files",
126    },
127    InstructionTemplate {
128        code: "ABBREV",
129        full: "Use abbreviations: fn, cfg, impl, deps, req, res, ctx, err",
130    },
131    InstructionTemplate {
132        code: "SYMBOLS",
133        full: "Use TDD notation: +=add -=remove ~=modify ->=returns ok/fail for status",
134    },
135];
136
137/// Build the decoder block that explains all instruction codes (sent once per session).
138pub fn instruction_decoder_block() -> String {
139    let mut lines = vec!["INSTRUCTION CODES:".to_string()];
140    for t in TEMPLATES {
141        lines.push(format!("  {} = {}", t.code, t.full));
142    }
143    lines.join("\n")
144}
145
146/// Encode an instruction suffix using short codes with budget hints.
147/// Response budget is dynamic based on task complexity to shape LLM output length.
148pub fn encode_instructions(complexity: &str) -> String {
149    match complexity {
150        "mechanical" => "MODE: ACT1 DELTA 1LINE | BUDGET: <=50 tokens, 1 line answer".to_string(),
151        "simple" => "MODE: BRIEF DELTA 1LINE | BUDGET: <=100 tokens, structured".to_string(),
152        "standard" => "MODE: BRIEF DELTA NOREPEAT STRUCT | BUDGET: <=200 tokens".to_string(),
153        "complex" => {
154            "MODE: FULL QUALITY NOREPEAT STRUCT FREF DIFF | BUDGET: <=500 tokens".to_string()
155        }
156        "architectural" => {
157            "MODE: FULL QUALITY NOREPEAT STRUCT FREF | BUDGET: unlimited".to_string()
158        }
159        _ => "MODE: BRIEF | BUDGET: <=200 tokens".to_string(),
160    }
161}
162
163/// Encode instructions with SNR metric for context quality awareness.
164pub fn encode_instructions_with_snr(complexity: &str, compression_pct: f64) -> String {
165    let snr = if compression_pct > 0.0 {
166        1.0 - (compression_pct / 100.0)
167    } else {
168        1.0
169    };
170    let base = encode_instructions(complexity);
171    format!("{base} | SNR: {snr:.2}")
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn is_project_root_marker_detects_git() {
180        let tmp = std::env::temp_dir().join("lean-ctx-test-root-marker");
181        let _ = std::fs::create_dir_all(&tmp);
182        let git_dir = tmp.join(".git");
183        let _ = std::fs::create_dir_all(&git_dir);
184        assert!(is_project_root_marker(&tmp));
185        let _ = std::fs::remove_dir_all(&tmp);
186    }
187
188    #[test]
189    fn is_project_root_marker_detects_cargo_toml() {
190        let tmp = std::env::temp_dir().join("lean-ctx-test-cargo-marker");
191        let _ = std::fs::create_dir_all(&tmp);
192        let _ = std::fs::write(tmp.join("Cargo.toml"), "[package]");
193        assert!(is_project_root_marker(&tmp));
194        let _ = std::fs::remove_dir_all(&tmp);
195    }
196
197    #[test]
198    fn detect_project_root_finds_outermost() {
199        let base = std::env::temp_dir().join("lean-ctx-test-monorepo");
200        let inner = base.join("packages").join("app");
201        let _ = std::fs::create_dir_all(&inner);
202        let _ = std::fs::create_dir_all(base.join(".git"));
203        let _ = std::fs::create_dir_all(inner.join(".git"));
204
205        let test_file = inner.join("main.rs");
206        let _ = std::fs::write(&test_file, "fn main() {}");
207
208        let root = detect_project_root(test_file.to_str().unwrap());
209        assert!(root.is_some(), "should find a project root for nested .git");
210        let root_path = std::path::PathBuf::from(root.unwrap());
211        assert_eq!(
212            root_path.canonicalize().ok(),
213            base.canonicalize().ok(),
214            "should return outermost .git, not inner"
215        );
216
217        let _ = std::fs::remove_dir_all(&base);
218    }
219
220    #[test]
221    fn decoder_block_contains_all_codes() {
222        let block = instruction_decoder_block();
223        for t in TEMPLATES {
224            assert!(
225                block.contains(t.code),
226                "decoder should contain code {}",
227                t.code
228            );
229        }
230    }
231
232    #[test]
233    fn encoded_instructions_are_compact() {
234        use super::super::tokens::count_tokens;
235        let full = "TASK COMPLEXITY: mechanical\nMinimal reasoning needed. Act immediately, report result in one line. Show only changed lines, not full files.";
236        let encoded = encode_instructions("mechanical");
237        assert!(
238            count_tokens(&encoded) <= count_tokens(full),
239            "encoded ({}) should be <= full ({})",
240            count_tokens(&encoded),
241            count_tokens(full)
242        );
243    }
244
245    #[test]
246    fn all_complexity_levels_encode() {
247        for level in &["mechanical", "standard", "architectural"] {
248            let encoded = encode_instructions(level);
249            assert!(encoded.starts_with("MODE:"), "should start with MODE:");
250        }
251    }
252}