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
44/// Returns the project root for `file_path`, falling back to cwd if none found.
45pub fn detect_project_root_or_cwd(file_path: &str) -> String {
46    detect_project_root(file_path).unwrap_or_else(|| {
47        let p = Path::new(file_path);
48        if p.exists() {
49            if p.is_dir() {
50                return file_path.to_string();
51            }
52            if let Some(parent) = p.parent() {
53                return parent.to_string_lossy().to_string();
54            }
55            return file_path.to_string();
56        }
57        std::env::current_dir()
58            .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
59    })
60}
61
62/// Returns the file name component of a path for compact display.
63pub fn shorten_path(path: &str) -> String {
64    let p = Path::new(path);
65    if let Some(name) = p.file_name() {
66        return name.to_string_lossy().to_string();
67    }
68    path.to_string()
69}
70
71/// Formats a token savings summary like `[42 tok saved (30%)]`.
72pub fn format_savings(original: usize, compressed: usize) -> String {
73    let saved = original.saturating_sub(compressed);
74    if original == 0 {
75        return "0 tok saved".to_string();
76    }
77    let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
78    format!("[{saved} tok saved ({pct}%)]")
79}
80
81/// A terse instruction code and its human-readable expansion.
82pub struct InstructionTemplate {
83    pub code: &'static str,
84    pub full: &'static str,
85}
86
87const TEMPLATES: &[InstructionTemplate] = &[
88    InstructionTemplate {
89        code: "ACT1",
90        full: "Act immediately, 1-line result",
91    },
92    InstructionTemplate {
93        code: "BRIEF",
94        full: "1-2 line approach, then act",
95    },
96    InstructionTemplate {
97        code: "FULL",
98        full: "Outline+edge cases, then act",
99    },
100    InstructionTemplate {
101        code: "DELTA",
102        full: "Changed lines only",
103    },
104    InstructionTemplate {
105        code: "NOREPEAT",
106        full: "No repeat, use Fn refs",
107    },
108    InstructionTemplate {
109        code: "STRUCT",
110        full: "+/-/~ notation",
111    },
112    InstructionTemplate {
113        code: "1LINE",
114        full: "1 line per action",
115    },
116    InstructionTemplate {
117        code: "NODOC",
118        full: "No narration comments",
119    },
120    InstructionTemplate {
121        code: "ACTFIRST",
122        full: "Tool calls first, no narration",
123    },
124    InstructionTemplate {
125        code: "QUALITY",
126        full: "Never skip edge cases",
127    },
128    InstructionTemplate {
129        code: "NOMOCK",
130        full: "No mock/placeholder data",
131    },
132    InstructionTemplate {
133        code: "FREF",
134        full: "Fn refs only, no full paths",
135    },
136    InstructionTemplate {
137        code: "DIFF",
138        full: "Diff lines only",
139    },
140    InstructionTemplate {
141        code: "ABBREV",
142        full: "fn,cfg,impl,deps,req,res,ctx,err",
143    },
144    InstructionTemplate {
145        code: "SYMBOLS",
146        full: "+=add -=rm ~=mod ->=ret",
147    },
148];
149
150/// Generates the INSTRUCTION CODES block for agent system prompts.
151pub fn instruction_decoder_block() -> String {
152    let pairs: Vec<String> = TEMPLATES
153        .iter()
154        .map(|t| format!("{}={}", t.code, t.full))
155        .collect();
156    format!("INSTRUCTION CODES:\n  {}", pairs.join(" | "))
157}
158
159/// Encode an instruction suffix using short codes with budget hints.
160/// Response budget is dynamic based on task complexity to shape LLM output length.
161pub fn encode_instructions(complexity: &str) -> String {
162    match complexity {
163        "mechanical" => "MODE: ACT1 DELTA 1LINE | BUDGET: <=50 tokens, 1 line answer".to_string(),
164        "simple" => "MODE: BRIEF DELTA 1LINE | BUDGET: <=100 tokens, structured".to_string(),
165        "standard" => "MODE: BRIEF DELTA NOREPEAT STRUCT | BUDGET: <=200 tokens".to_string(),
166        "complex" => {
167            "MODE: FULL QUALITY NOREPEAT STRUCT FREF DIFF | BUDGET: <=500 tokens".to_string()
168        }
169        "architectural" => {
170            "MODE: FULL QUALITY NOREPEAT STRUCT FREF | BUDGET: unlimited".to_string()
171        }
172        _ => "MODE: BRIEF | BUDGET: <=200 tokens".to_string(),
173    }
174}
175
176/// Encode instructions with SNR metric for context quality awareness.
177pub fn encode_instructions_with_snr(complexity: &str, compression_pct: f64) -> String {
178    let snr = if compression_pct > 0.0 {
179        1.0 - (compression_pct / 100.0)
180    } else {
181        1.0
182    };
183    let base = encode_instructions(complexity);
184    format!("{base} | SNR: {snr:.2}")
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn is_project_root_marker_detects_git() {
193        let tmp = std::env::temp_dir().join("lean-ctx-test-root-marker");
194        let _ = std::fs::create_dir_all(&tmp);
195        let git_dir = tmp.join(".git");
196        let _ = std::fs::create_dir_all(&git_dir);
197        assert!(is_project_root_marker(&tmp));
198        let _ = std::fs::remove_dir_all(&tmp);
199    }
200
201    #[test]
202    fn is_project_root_marker_detects_cargo_toml() {
203        let tmp = std::env::temp_dir().join("lean-ctx-test-cargo-marker");
204        let _ = std::fs::create_dir_all(&tmp);
205        let _ = std::fs::write(tmp.join("Cargo.toml"), "[package]");
206        assert!(is_project_root_marker(&tmp));
207        let _ = std::fs::remove_dir_all(&tmp);
208    }
209
210    #[test]
211    fn detect_project_root_finds_outermost() {
212        let base = std::env::temp_dir().join("lean-ctx-test-monorepo");
213        let inner = base.join("packages").join("app");
214        let _ = std::fs::create_dir_all(&inner);
215        let _ = std::fs::create_dir_all(base.join(".git"));
216        let _ = std::fs::create_dir_all(inner.join(".git"));
217
218        let test_file = inner.join("main.rs");
219        let _ = std::fs::write(&test_file, "fn main() {}");
220
221        let root = detect_project_root(test_file.to_str().unwrap());
222        assert!(root.is_some(), "should find a project root for nested .git");
223        let root_path = std::path::PathBuf::from(root.unwrap());
224        assert_eq!(
225            crate::core::pathutil::safe_canonicalize(&root_path).ok(),
226            crate::core::pathutil::safe_canonicalize(&base).ok(),
227            "should return outermost .git, not inner"
228        );
229
230        let _ = std::fs::remove_dir_all(&base);
231    }
232
233    #[test]
234    fn decoder_block_contains_all_codes() {
235        let block = instruction_decoder_block();
236        for t in TEMPLATES {
237            assert!(
238                block.contains(t.code),
239                "decoder should contain code {}",
240                t.code
241            );
242        }
243    }
244
245    #[test]
246    fn encoded_instructions_are_compact() {
247        use super::super::tokens::count_tokens;
248        let full = "TASK COMPLEXITY: mechanical\nMinimal reasoning needed. Act immediately, report result in one line. Show only changed lines, not full files.";
249        let encoded = encode_instructions("mechanical");
250        assert!(
251            count_tokens(&encoded) <= count_tokens(full),
252            "encoded ({}) should be <= full ({})",
253            count_tokens(&encoded),
254            count_tokens(full)
255        );
256    }
257
258    #[test]
259    fn all_complexity_levels_encode() {
260        for level in &["mechanical", "standard", "architectural"] {
261            let encoded = encode_instructions(level);
262            assert!(encoded.starts_with("MODE:"), "should start with MODE:");
263        }
264    }
265}