Skip to main content

lean_ctx/shell/
output_policy.rs

1/// Central command output classification.
2///
3/// Every shell command flows through `classify` before any compression
4/// decision is made. This is the SINGLE source of truth — no other
5/// code path may bypass it.
6///
7/// Priority (first match wins):
8///   1. User `excluded_commands` config      → Passthrough
9///   2. `BUILTIN_PASSTHROUGH` + dev scripts  → Passthrough
10///   3. Verbatim data commands               → Verbatim
11///   4. Everything else                      → Compressible
12///      (pattern engine decides specific vs generic later)
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum OutputPolicy {
16    /// Auth flows, dev servers, interactive, streaming, installs.
17    /// Output is passed through with ZERO modification, even when
18    /// `LEAN_CTX_COMPRESS=1` (force_compress) is set.
19    Passthrough,
20
21    /// API data, file content, structured queries, HTTP responses.
22    /// Output is preserved as-is. Only a hard size-cap is applied
23    /// when the output exceeds the context window limit.
24    Verbatim,
25
26    /// Build, test, lint, package manager, git action output.
27    /// Domain-specific pattern compression is applied, then
28    /// generic fallback if no pattern matches.
29    Compressible,
30}
31
32impl OutputPolicy {
33    /// Returns true if the output MUST NOT be compressed under any
34    /// circumstances (not even truncated, except for catastrophic size).
35    pub fn is_protected(&self) -> bool {
36        matches!(self, Self::Passthrough | Self::Verbatim)
37    }
38}
39
40/// Classify a command into an `OutputPolicy`.
41///
42/// `user_excluded` comes from `Config::excluded_commands`. Precedence:
43///   1. `is_passthrough` (BUILTIN_PASSTHROUGH + dev-script runners + user excludes)
44///   2. `compress::is_verbatim_output` (HTTP clients, file viewers, data formats …)
45///   3. otherwise `Compressible`
46pub fn classify(command: &str, user_excluded: &[String]) -> OutputPolicy {
47    if is_passthrough(command, user_excluded) {
48        return OutputPolicy::Passthrough;
49    }
50    if super::compress::is_verbatim_output(command) {
51        return OutputPolicy::Verbatim;
52    }
53    OutputPolicy::Compressible
54}
55
56fn is_passthrough(command: &str, user_excluded: &[String]) -> bool {
57    super::compress::is_excluded_command(command, user_excluded)
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    #[test]
65    fn gh_auth_is_passthrough() {
66        assert_eq!(classify("gh auth login", &[]), OutputPolicy::Passthrough);
67    }
68
69    #[test]
70    fn gh_api_is_verbatim() {
71        // gh api returns raw JSON data — should be verbatim
72        assert_eq!(
73            classify("gh api repos/owner/repo/issues", &[]),
74            OutputPolicy::Verbatim
75        );
76    }
77
78    #[test]
79    fn user_excluded_is_passthrough() {
80        let excl = vec!["mycommand".to_string()];
81        assert_eq!(
82            classify("mycommand --flag", &excl),
83            OutputPolicy::Passthrough
84        );
85    }
86
87    #[test]
88    fn curl_is_verbatim() {
89        assert_eq!(
90            classify("curl https://api.example.com", &[]),
91            OutputPolicy::Verbatim
92        );
93    }
94
95    #[test]
96    fn cat_is_verbatim() {
97        assert_eq!(classify("cat package.json", &[]), OutputPolicy::Verbatim);
98    }
99
100    #[test]
101    fn cargo_build_is_compressible() {
102        assert_eq!(classify("cargo build", &[]), OutputPolicy::Compressible);
103    }
104
105    #[test]
106    fn npm_test_is_compressible() {
107        assert_eq!(classify("npm test", &[]), OutputPolicy::Compressible);
108    }
109
110    #[test]
111    fn dev_server_is_passthrough() {
112        assert_eq!(classify("npm run dev", &[]), OutputPolicy::Passthrough);
113        assert_eq!(classify("cargo watch", &[]), OutputPolicy::Passthrough);
114        assert_eq!(classify("cargo run", &[]), OutputPolicy::Passthrough);
115    }
116
117    #[test]
118    fn git_diff_is_verbatim() {
119        // git diff is structural -> verbatim path in compress_if_beneficial,
120        // but the is_verbatim_output check via is_git_data_command should
121        // also catch structural git commands. If not, it's at least
122        // Compressible (structural pattern). Let's verify:
123        let policy = classify("git diff", &[]);
124        // git diff is not in BUILTIN_PASSTHROUGH, not in is_verbatim_output
125        // (it's in is_structural_git_command which feeds has_structural_output
126        // but NOT is_verbatim_output). So it's Compressible, but compress.rs
127        // handles it specially via has_structural_output.
128        assert_eq!(policy, OutputPolicy::Compressible);
129    }
130
131    #[test]
132    fn auth_commands_are_passthrough() {
133        assert_eq!(classify("az login", &[]), OutputPolicy::Passthrough);
134        assert_eq!(
135            classify("gcloud auth login", &[]),
136            OutputPolicy::Passthrough
137        );
138        assert_eq!(classify("firebase login", &[]), OutputPolicy::Passthrough);
139    }
140
141    #[test]
142    fn jq_is_verbatim() {
143        assert_eq!(
144            classify("jq '.items' data.json", &[]),
145            OutputPolicy::Verbatim
146        );
147    }
148
149    #[test]
150    fn unknown_command_is_compressible() {
151        assert_eq!(
152            classify("some-random-tool --flag", &[]),
153            OutputPolicy::Compressible
154        );
155    }
156
157    #[test]
158    fn piped_jq_is_verbatim() {
159        assert_eq!(
160            classify("kubectl get pods -o json | jq '.items[]'", &[]),
161            OutputPolicy::Verbatim
162        );
163    }
164
165    #[test]
166    fn policy_is_protected() {
167        assert!(OutputPolicy::Passthrough.is_protected());
168        assert!(OutputPolicy::Verbatim.is_protected());
169        assert!(!OutputPolicy::Compressible.is_protected());
170    }
171
172    // --- Regression tests for GitHub Issues ---
173
174    #[test]
175    fn issue_198_gh_api_jq() {
176        // gh api returns JSON — verbatim (API data)
177        assert_eq!(
178            classify("gh api repos/yvgude/lean-ctx/issues/198 --jq '.body'", &[]),
179            OutputPolicy::Verbatim
180        );
181    }
182
183    #[test]
184    fn issue_159_cat_pubspec() {
185        assert_eq!(classify("cat pubspec.yaml", &[]), OutputPolicy::Verbatim);
186    }
187
188    #[test]
189    fn issue_114_git_stash() {
190        // git stash list/show should not be over-compressed
191        // "git stash" without subcommand is not in passthrough,
192        // and is_verbatim_output doesn't match plain "git stash".
193        // But "git stash show" is structural.
194        let p = classify("git stash show", &[]);
195        assert_eq!(p, OutputPolicy::Compressible);
196    }
197
198    #[test]
199    fn issue_194_git_diff_raw() {
200        // git diff/show output should be preserved
201        let p = classify("git diff --cached", &[]);
202        assert_eq!(p, OutputPolicy::Compressible);
203    }
204
205    #[test]
206    fn npm_install_is_compressible() {
207        // npm install output is build-like; compressed via npm pattern
208        assert_eq!(
209            classify("npm install -g deepseek-tui", &[]),
210            OutputPolicy::Compressible
211        );
212    }
213
214    #[test]
215    fn pip_install_is_compressible() {
216        assert_eq!(
217            classify("pip install flask", &[]),
218            OutputPolicy::Compressible
219        );
220    }
221
222    #[test]
223    fn kubectl_get_yaml_is_verbatim() {
224        assert_eq!(
225            classify("kubectl get pods -o yaml", &[]),
226            OutputPolicy::Verbatim
227        );
228    }
229
230    #[test]
231    fn docker_inspect_is_verbatim() {
232        assert_eq!(
233            classify("docker inspect my-container", &[]),
234            OutputPolicy::Verbatim
235        );
236    }
237
238    #[test]
239    fn terraform_output_is_verbatim() {
240        assert_eq!(classify("terraform output", &[]), OutputPolicy::Verbatim);
241    }
242
243    #[test]
244    fn heroku_logs_is_verbatim() {
245        assert_eq!(classify("heroku logs --tail", &[]), OutputPolicy::Verbatim);
246    }
247
248    #[test]
249    fn gh_pr_list_is_compressible() {
250        assert_eq!(classify("gh pr list", &[]), OutputPolicy::Compressible);
251    }
252
253    #[test]
254    fn lean_ctx_is_passthrough() {
255        assert_eq!(
256            classify("lean-ctx init powershell", &[]),
257            OutputPolicy::Passthrough
258        );
259        assert_eq!(
260            classify("lean-ctx overview", &[]),
261            OutputPolicy::Passthrough
262        );
263    }
264
265    #[test]
266    fn stripe_list_is_verbatim() {
267        assert_eq!(classify("stripe charges list", &[]), OutputPolicy::Verbatim);
268    }
269
270    // --- Regression: daviddatu_ git command rewriting bug ---
271
272    #[test]
273    fn git_commit_is_verbatim() {
274        assert_eq!(
275            classify("git commit -m \"feat: add feature\"", &[]),
276            OutputPolicy::Verbatim
277        );
278    }
279
280    #[test]
281    fn git_push_is_verbatim() {
282        assert_eq!(
283            classify("git push origin main", &[]),
284            OutputPolicy::Verbatim
285        );
286    }
287
288    #[test]
289    fn git_pull_is_verbatim() {
290        assert_eq!(classify("git pull --rebase", &[]), OutputPolicy::Verbatim);
291    }
292
293    #[test]
294    fn git_merge_is_verbatim() {
295        assert_eq!(
296            classify("git merge feature-branch", &[]),
297            OutputPolicy::Verbatim
298        );
299    }
300
301    #[test]
302    fn git_rebase_is_verbatim() {
303        assert_eq!(classify("git rebase main", &[]), OutputPolicy::Verbatim);
304    }
305
306    #[test]
307    fn git_cherry_pick_is_verbatim() {
308        assert_eq!(
309            classify("git cherry-pick abc1234", &[]),
310            OutputPolicy::Verbatim
311        );
312    }
313
314    #[test]
315    fn git_tag_is_verbatim() {
316        assert_eq!(classify("git tag v1.0.0", &[]), OutputPolicy::Verbatim);
317    }
318
319    #[test]
320    fn git_status_still_compressible() {
321        assert_eq!(classify("git status", &[]), OutputPolicy::Compressible);
322    }
323
324    #[test]
325    fn git_log_still_compressible() {
326        assert_eq!(
327            classify("git log --oneline", &[]),
328            OutputPolicy::Compressible
329        );
330    }
331}