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/// Whether `command` matches one of the user's `excluded_commands` entries:
61/// case-insensitive, the whole command or the entry followed by a space
62/// (`git` matches `git status`, not `gitk`). The one matcher both the shell
63/// path ([`classify`]) and the `hook rewrite` entry point use (#2194).
64pub fn is_user_excluded(command: &str, user_excluded: &[String]) -> bool {
65    if user_excluded.is_empty() {
66        return false;
67    }
68    let cmd = command.trim().to_lowercase();
69    user_excluded.iter().any(|excl| {
70        let excl_lower = excl.trim().to_lowercase();
71        !excl_lower.is_empty() && (cmd == excl_lower || cmd.starts_with(&format!("{excl_lower} ")))
72    })
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn gh_auth_is_passthrough() {
81        assert_eq!(classify("gh auth login", &[]), OutputPolicy::Passthrough);
82    }
83
84    #[test]
85    fn gh_api_is_verbatim() {
86        // gh api returns raw JSON data — should be verbatim
87        assert_eq!(
88            classify("gh api repos/owner/repo/issues", &[]),
89            OutputPolicy::Verbatim
90        );
91    }
92
93    #[test]
94    fn user_excluded_is_passthrough() {
95        let excl = vec!["mycommand".to_string()];
96        assert_eq!(
97            classify("mycommand --flag", &excl),
98            OutputPolicy::Passthrough
99        );
100    }
101
102    #[test]
103    fn curl_is_verbatim() {
104        assert_eq!(
105            classify("curl https://api.example.com", &[]),
106            OutputPolicy::Verbatim
107        );
108    }
109
110    #[test]
111    fn cat_is_verbatim() {
112        assert_eq!(classify("cat package.json", &[]), OutputPolicy::Verbatim);
113    }
114
115    #[test]
116    fn cargo_build_is_compressible() {
117        assert_eq!(classify("cargo build", &[]), OutputPolicy::Compressible);
118    }
119
120    #[test]
121    fn npm_test_is_compressible() {
122        assert_eq!(classify("npm test", &[]), OutputPolicy::Compressible);
123    }
124
125    #[test]
126    fn dev_server_is_passthrough() {
127        assert_eq!(classify("npm run dev", &[]), OutputPolicy::Passthrough);
128        assert_eq!(classify("cargo watch", &[]), OutputPolicy::Passthrough);
129        assert_eq!(classify("cargo run", &[]), OutputPolicy::Passthrough);
130    }
131
132    #[test]
133    fn git_diff_is_verbatim() {
134        // git diff is structural -> verbatim path in compress_if_beneficial,
135        // but the is_verbatim_output check via is_git_data_command should
136        // also catch structural git commands. If not, it's at least
137        // Compressible (structural pattern). Let's verify:
138        let policy = classify("git diff", &[]);
139        // git diff is not in BUILTIN_PASSTHROUGH, not in is_verbatim_output
140        // (it's in is_structural_git_command which feeds has_structural_output
141        // but NOT is_verbatim_output). So it's Compressible, but compress.rs
142        // handles it specially via has_structural_output.
143        assert_eq!(policy, OutputPolicy::Compressible);
144    }
145
146    #[test]
147    fn auth_commands_are_passthrough() {
148        assert_eq!(classify("az login", &[]), OutputPolicy::Passthrough);
149        assert_eq!(
150            classify("gcloud auth login", &[]),
151            OutputPolicy::Passthrough
152        );
153        assert_eq!(classify("firebase login", &[]), OutputPolicy::Passthrough);
154    }
155
156    #[test]
157    fn jq_is_verbatim() {
158        assert_eq!(
159            classify("jq '.items' data.json", &[]),
160            OutputPolicy::Verbatim
161        );
162    }
163
164    #[test]
165    fn unknown_command_is_compressible() {
166        assert_eq!(
167            classify("some-random-tool --flag", &[]),
168            OutputPolicy::Compressible
169        );
170    }
171
172    #[test]
173    fn piped_jq_is_verbatim() {
174        assert_eq!(
175            classify("kubectl get pods -o json | jq '.items[]'", &[]),
176            OutputPolicy::Verbatim
177        );
178    }
179
180    #[test]
181    fn policy_is_protected() {
182        assert!(OutputPolicy::Passthrough.is_protected());
183        assert!(OutputPolicy::Verbatim.is_protected());
184        assert!(!OutputPolicy::Compressible.is_protected());
185    }
186
187    // --- Regression tests for GitHub Issues ---
188
189    #[test]
190    fn issue_198_gh_api_jq() {
191        // gh api returns JSON — verbatim (API data)
192        assert_eq!(
193            classify("gh api repos/yvgude/lean-ctx/issues/198 --jq '.body'", &[]),
194            OutputPolicy::Verbatim
195        );
196    }
197
198    #[test]
199    fn issue_159_cat_pubspec() {
200        assert_eq!(classify("cat pubspec.yaml", &[]), OutputPolicy::Verbatim);
201    }
202
203    #[test]
204    fn issue_114_git_stash() {
205        // git stash list/show should not be over-compressed
206        // "git stash" without subcommand is not in passthrough,
207        // and is_verbatim_output doesn't match plain "git stash".
208        // But "git stash show" is structural.
209        let p = classify("git stash show", &[]);
210        assert_eq!(p, OutputPolicy::Compressible);
211    }
212
213    #[test]
214    fn issue_194_git_diff_raw() {
215        // git diff/show output should be preserved
216        let p = classify("git diff --cached", &[]);
217        assert_eq!(p, OutputPolicy::Compressible);
218    }
219
220    #[test]
221    fn npm_install_is_compressible() {
222        // npm install output is build-like; compressed via npm pattern
223        assert_eq!(
224            classify("npm install -g deepseek-tui", &[]),
225            OutputPolicy::Compressible
226        );
227    }
228
229    #[test]
230    fn pip_install_is_compressible() {
231        assert_eq!(
232            classify("pip install flask", &[]),
233            OutputPolicy::Compressible
234        );
235    }
236
237    #[test]
238    fn kubectl_get_yaml_is_verbatim() {
239        assert_eq!(
240            classify("kubectl get pods -o yaml", &[]),
241            OutputPolicy::Verbatim
242        );
243    }
244
245    #[test]
246    fn docker_inspect_is_verbatim() {
247        assert_eq!(
248            classify("docker inspect my-container", &[]),
249            OutputPolicy::Verbatim
250        );
251    }
252
253    #[test]
254    fn terraform_output_is_verbatim() {
255        assert_eq!(classify("terraform output", &[]), OutputPolicy::Verbatim);
256    }
257
258    #[test]
259    fn heroku_logs_is_verbatim() {
260        assert_eq!(classify("heroku logs --tail", &[]), OutputPolicy::Verbatim);
261    }
262
263    #[test]
264    fn gh_pr_list_is_compressible() {
265        assert_eq!(classify("gh pr list", &[]), OutputPolicy::Compressible);
266    }
267
268    #[test]
269    fn emspace_data_is_verbatim_and_listings_compressible() {
270        for data in [
271            "emspace page show core --json",
272            "emspace page show --body core",
273            "emspace schema pages",
274            "emspace run --query prog.js",
275        ] {
276            assert_eq!(classify(data, &[]), OutputPolicy::Verbatim, "{data}");
277        }
278        assert_eq!(
279            classify("emspace search budget", &[]),
280            OutputPolicy::Compressible
281        );
282        assert_eq!(
283            classify("emspace page show core", &[]),
284            OutputPolicy::Compressible
285        );
286    }
287
288    #[test]
289    fn lean_ctx_is_passthrough() {
290        assert_eq!(
291            classify("lean-ctx init powershell", &[]),
292            OutputPolicy::Passthrough
293        );
294        assert_eq!(
295            classify("lean-ctx overview", &[]),
296            OutputPolicy::Passthrough
297        );
298    }
299
300    #[test]
301    fn stripe_list_is_verbatim() {
302        assert_eq!(classify("stripe charges list", &[]), OutputPolicy::Verbatim);
303    }
304
305    // --- Regression: daviddatu_ git command rewriting bug ---
306
307    #[test]
308    fn git_commit_is_passthrough() {
309        assert_eq!(
310            classify("git commit -m \"feat: add feature\"", &[]),
311            OutputPolicy::Passthrough
312        );
313    }
314
315    #[test]
316    fn git_push_is_passthrough() {
317        assert_eq!(
318            classify("git push origin main", &[]),
319            OutputPolicy::Passthrough
320        );
321    }
322
323    #[test]
324    fn git_pull_is_passthrough() {
325        assert_eq!(
326            classify("git pull --rebase", &[]),
327            OutputPolicy::Passthrough
328        );
329    }
330
331    #[test]
332    fn git_merge_is_passthrough() {
333        assert_eq!(
334            classify("git merge feature-branch", &[]),
335            OutputPolicy::Passthrough
336        );
337    }
338
339    #[test]
340    fn git_rebase_is_passthrough() {
341        assert_eq!(classify("git rebase main", &[]), OutputPolicy::Passthrough);
342    }
343
344    #[test]
345    fn git_cherry_pick_is_passthrough() {
346        assert_eq!(
347            classify("git cherry-pick abc1234", &[]),
348            OutputPolicy::Passthrough
349        );
350    }
351
352    #[test]
353    fn git_tag_is_passthrough() {
354        assert_eq!(classify("git tag v1.0.0", &[]), OutputPolicy::Passthrough);
355    }
356
357    #[test]
358    fn git_status_still_compressible() {
359        assert_eq!(classify("git status", &[]), OutputPolicy::Compressible);
360    }
361
362    #[test]
363    fn git_log_still_compressible() {
364        assert_eq!(
365            classify("git log --oneline", &[]),
366            OutputPolicy::Compressible
367        );
368    }
369}