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 emspace_data_is_verbatim_and_listings_compressible() {
255        for data in [
256            "emspace page show core --json",
257            "emspace page show --body core",
258            "emspace schema pages",
259            "emspace run --query prog.js",
260        ] {
261            assert_eq!(classify(data, &[]), OutputPolicy::Verbatim, "{data}");
262        }
263        assert_eq!(
264            classify("emspace search budget", &[]),
265            OutputPolicy::Compressible
266        );
267        assert_eq!(
268            classify("emspace page show core", &[]),
269            OutputPolicy::Compressible
270        );
271    }
272
273    #[test]
274    fn lean_ctx_is_passthrough() {
275        assert_eq!(
276            classify("lean-ctx init powershell", &[]),
277            OutputPolicy::Passthrough
278        );
279        assert_eq!(
280            classify("lean-ctx overview", &[]),
281            OutputPolicy::Passthrough
282        );
283    }
284
285    #[test]
286    fn stripe_list_is_verbatim() {
287        assert_eq!(classify("stripe charges list", &[]), OutputPolicy::Verbatim);
288    }
289
290    // --- Regression: daviddatu_ git command rewriting bug ---
291
292    #[test]
293    fn git_commit_is_passthrough() {
294        assert_eq!(
295            classify("git commit -m \"feat: add feature\"", &[]),
296            OutputPolicy::Passthrough
297        );
298    }
299
300    #[test]
301    fn git_push_is_passthrough() {
302        assert_eq!(
303            classify("git push origin main", &[]),
304            OutputPolicy::Passthrough
305        );
306    }
307
308    #[test]
309    fn git_pull_is_passthrough() {
310        assert_eq!(
311            classify("git pull --rebase", &[]),
312            OutputPolicy::Passthrough
313        );
314    }
315
316    #[test]
317    fn git_merge_is_passthrough() {
318        assert_eq!(
319            classify("git merge feature-branch", &[]),
320            OutputPolicy::Passthrough
321        );
322    }
323
324    #[test]
325    fn git_rebase_is_passthrough() {
326        assert_eq!(classify("git rebase main", &[]), OutputPolicy::Passthrough);
327    }
328
329    #[test]
330    fn git_cherry_pick_is_passthrough() {
331        assert_eq!(
332            classify("git cherry-pick abc1234", &[]),
333            OutputPolicy::Passthrough
334        );
335    }
336
337    #[test]
338    fn git_tag_is_passthrough() {
339        assert_eq!(classify("git tag v1.0.0", &[]), OutputPolicy::Passthrough);
340    }
341
342    #[test]
343    fn git_status_still_compressible() {
344        assert_eq!(classify("git status", &[]), OutputPolicy::Compressible);
345    }
346
347    #[test]
348    fn git_log_still_compressible() {
349        assert_eq!(
350            classify("git log --oneline", &[]),
351            OutputPolicy::Compressible
352        );
353    }
354}