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