Skip to main content

tokenfold_core/
pipeline.rs

1use crate::budget::{CompressionMode, CompressionPolicy, TaskScope, protected_segments};
2use crate::errors::TokenFoldError;
3use crate::input::{CompressionInput, CompressionOutput, InputFormat};
4use crate::modes::{self, ModeEntry, TransformId};
5use crate::report::{
6    BudgetReport, CompressionReport, RetrievalReport, Severity, SkippedReason, TransformReport,
7    TransformStatus, Warning, WarningCode,
8};
9use crate::retrieval_store::{self, RetrievalStore};
10use crate::safety;
11use crate::status::Status;
12use crate::token_estimator::{ByteHeuristicEstimator, TokenEstimator};
13use crate::transforms;
14
15/// Compresses `input` under `policy` using the best available estimator (exact `tiktoken`
16/// when the feature is compiled in and its data is reachable, heuristic otherwise).
17pub fn compress(
18    input: CompressionInput,
19    policy: &CompressionPolicy,
20) -> Result<CompressionOutput, TokenFoldError> {
21    #[cfg(feature = "tiktoken")]
22    {
23        if let Ok(estimator) = crate::token_estimator::TiktokenEstimator::o200k_base() {
24            return compress_with_estimator(input, policy, &estimator);
25        }
26    }
27    compress_with_estimator(input, policy, &ByteHeuristicEstimator)
28}
29
30pub fn compress_with_estimator(
31    input: CompressionInput,
32    policy: &CompressionPolicy,
33    estimator: &dyn TokenEstimator,
34) -> Result<CompressionOutput, TokenFoldError> {
35    let original_tokens = estimator.count_bytes(&input.bytes);
36    let target = policy.target_tokens;
37    let estimator_info = estimator.info();
38
39    // F-045: whole-payload evidence store, best-effort. Runs against the full pre-transform
40    // input regardless of which status path below is taken, so it must be computed up front.
41    let retrieval = maybe_store_originals(&input.bytes, policy);
42
43    // Passthrough is checked before any transform (including redaction) runs: F-001 requires
44    // input bytes to stay byte-for-byte unchanged in this case.
45    if let Some(t) = target
46        && original_tokens <= t
47    {
48        let mut warnings = Vec::new();
49        if !estimator_info.is_exact {
50            warnings.push(heuristic_budget_warning());
51        }
52        let mut report = CompressionReport::new(
53            original_tokens,
54            original_tokens,
55            estimator_info,
56            Status::Passthrough,
57            mode_label(policy.mode).to_string(),
58            format_label(input.format).to_string(),
59            task_scope_label(policy.task_scope).to_string(),
60            Vec::new(),
61            warnings,
62        );
63        report.retrieval = retrieval;
64        return Ok(CompressionOutput {
65            bytes: input.bytes,
66            report,
67        });
68    }
69
70    apply_transforms(input, policy, estimator, original_tokens, target, retrieval)
71}
72
73/// F-045: when `policy.store_originals` is set, persists the full pre-transform input to the
74/// configured reversible evidence store (`policy.retrieval_backend`/`retrieval_store_path`)
75/// under its SHA-256 hash, unless it contains secret-shaped content (`RetrievalStore::store`'s
76/// own unconditional gate — never bypassable from here). Best-effort: any storage failure
77/// (an unopenable store, e.g. the documented `backend = "sqlite"` scope cut, or the secret
78/// gate) is reported as `skipped_original_bytes`, never as a compression error.
79fn maybe_store_originals(
80    input_bytes: &[u8],
81    policy: &CompressionPolicy,
82) -> Option<RetrievalReport> {
83    if !policy.store_originals {
84        return None;
85    }
86    let ttl_seconds = policy
87        .retrieval_ttl_seconds
88        .unwrap_or(retrieval_store::DEFAULT_TTL_SECONDS);
89    let skipped = || RetrievalReport {
90        store_namespace: policy.retrieval_namespace.clone(),
91        hash_algorithm: "sha256".to_string(),
92        marker_count: 0,
93        ttl_seconds: None,
94        persisted_original_bytes: 0,
95        skipped_original_bytes: input_bytes.len(),
96    };
97    let Ok(store) = RetrievalStore::open(
98        &policy.retrieval_backend,
99        "sha256",
100        policy.retrieval_store_path.clone(),
101    ) else {
102        return Some(skipped());
103    };
104    Some(
105        match store.store(input_bytes, &policy.retrieval_namespace, Some(ttl_seconds)) {
106            Ok(_marker) => RetrievalReport {
107                store_namespace: policy.retrieval_namespace.clone(),
108                hash_algorithm: "sha256".to_string(),
109                marker_count: 1,
110                ttl_seconds: Some(ttl_seconds),
111                persisted_original_bytes: input_bytes.len(),
112                skipped_original_bytes: 0,
113            },
114            Err(_) => skipped(),
115        },
116    )
117}
118
119fn apply_transforms(
120    input: CompressionInput,
121    policy: &CompressionPolicy,
122    estimator: &dyn TokenEstimator,
123    original_tokens: usize,
124    target: Option<usize>,
125    retrieval: Option<RetrievalReport>,
126) -> Result<CompressionOutput, TokenFoldError> {
127    let estimator_info = estimator.info();
128    let mut warnings = Vec::new();
129    let mut transform_reports = Vec::new();
130    if !estimator_info.is_exact {
131        warnings.push(heuristic_budget_warning());
132    }
133
134    // Step 1: secret_redaction — mandatory, always first, cannot be disabled via `disabled`
135    // (CompressionPolicyBuilder::build rejects that). The only bypass is the CLI-only
136    // `unsafe_disable_redaction` escape hatch, which emits a Critical warning instead.
137    let mut bytes;
138    if policy.unsafe_disable_redaction {
139        bytes = input.bytes.clone();
140        warnings.push(Warning {
141            code: WarningCode::UnredactedContentPossible,
142            severity: Severity::Critical,
143            transform: Some("secret_redaction".to_string()),
144            message: "redaction was disabled via unsafe_disable_redaction; output may contain unredacted secrets".to_string(),
145        });
146        transform_reports.push(skipped_at(
147            "secret_redaction",
148            "1.0.0",
149            original_tokens,
150            SkippedReason::DisabledByUser,
151        ));
152    } else {
153        let outcome = transforms::redaction::redact(&input.bytes);
154        let tokens_after = estimator.count_bytes(&outcome.bytes);
155        warnings.push(Warning {
156            code: WarningCode::UnredactedContentPossible,
157            severity: Severity::Info,
158            transform: Some("secret_redaction".to_string()),
159            message: "redaction is best-effort; it is not a guarantee that no secret survives"
160                .to_string(),
161        });
162        transform_reports.push(TransformReport {
163            id: "secret_redaction".to_string(),
164            version: "1.0.0".to_string(),
165            tokens_before: original_tokens,
166            tokens_after,
167            saved_tokens: original_tokens.saturating_sub(tokens_after),
168            savings_ratio: ratio(original_tokens, tokens_after),
169            elapsed_micros: None,
170            status: if outcome.redacted_count > 0 {
171                TransformStatus::Applied
172            } else {
173                TransformStatus::NoOp
174            },
175            skipped_reason: None,
176            warnings: Vec::new(),
177        });
178        bytes = outcome.bytes;
179    }
180
181    // Protected content is computed against the POST-redaction view: redaction may
182    // legitimately alter protected content that itself contained a secret, so later
183    // transforms are held to "survives redaction", not "survives the original bytes".
184    let working_input = CompressionInput {
185        format: input.format,
186        bytes: bytes.clone(),
187    };
188    let protected = protected_segments(&working_input, policy);
189    let floor = estimator.count_bytes(&protected.concat());
190    let mut current_tokens = estimator.count_bytes(&bytes);
191
192    if let Some(t) = target
193        && t < floor
194    {
195        warnings.push(Warning {
196            code: WarningCode::UnreachableTarget,
197            severity: Severity::Warn,
198            transform: None,
199            message: format!("target {t} tokens is below the protected floor of {floor} tokens"),
200        });
201        let mut report = CompressionReport::new(
202            original_tokens,
203            current_tokens,
204            estimator_info,
205            Status::UnreachableTarget,
206            mode_label(policy.mode).to_string(),
207            format_label(input.format).to_string(),
208            task_scope_label(policy.task_scope).to_string(),
209            transform_reports,
210            warnings,
211        );
212        report.budget = Some(BudgetReport {
213            target_tokens: target,
214            protected_floor: floor,
215            achieved_tokens: current_tokens,
216        });
217        report.retrieval = retrieval;
218        return Ok(CompressionOutput { bytes, report });
219    }
220
221    // Step 2: mode-matrix-selected transforms, in canonical order, stopping early once the
222    // target is met (INTERFACES.md Part 2 "Early Exit").
223    let entries = modes::pipeline_for(
224        policy.mode,
225        policy.task_scope,
226        input.format,
227        policy.experimental,
228        &policy.enable,
229        &policy.disabled,
230    );
231    for entry in entries {
232        if let Some(t) = target
233            && current_tokens <= t
234        {
235            transform_reports.push(skipped(
236                entry,
237                current_tokens,
238                SkippedReason::TargetAlreadyMet,
239            ));
240            continue;
241        }
242
243        let tokens_before = current_tokens;
244        let before_bytes = bytes.clone();
245        let max_ratio = entry.max_ratio_for(policy.mode);
246
247        let candidate = match apply_single_transform(entry.transform_id, &bytes, policy) {
248            Ok(candidate) => candidate,
249            Err(_) => {
250                transform_reports.push(skipped(
251                    entry,
252                    tokens_before,
253                    SkippedReason::NotApplicableToFormat,
254                ));
255                continue;
256            }
257        };
258
259        let tokens_after_candidate = estimator.count_bytes(&candidate);
260        if tokens_after_candidate > tokens_before {
261            // A genuine regression: never adopt a transform that costs more tokens than it saves.
262            transform_reports.push(skipped(
263                entry,
264                tokens_before,
265                SkippedReason::WouldIncreaseTokens,
266            ));
267            continue;
268        }
269        if tokens_after_candidate == tokens_before {
270            // The transform ran (unlike the cases above/below, which never call it) but had no
271            // measurable effect — that's NoOp, not Skipped, per the TransformStatus contract.
272            transform_reports.push(TransformReport {
273                id: entry.transform_id.as_str().to_string(),
274                version: entry.version.to_string(),
275                tokens_before,
276                tokens_after: tokens_before,
277                saved_tokens: 0,
278                savings_ratio: 0.0,
279                elapsed_micros: None,
280                status: TransformStatus::NoOp,
281                skipped_reason: None,
282                warnings: Vec::new(),
283            });
284            continue;
285        }
286        let ratio_used = 1.0 - (tokens_after_candidate as f64 / tokens_before.max(1) as f64);
287        if ratio_used > max_ratio {
288            transform_reports.push(skipped(
289                entry,
290                tokens_before,
291                SkippedReason::NotEnabledInMode,
292            ));
293            continue;
294        }
295
296        if !validate_safety(
297            entry.transform_id,
298            input.format,
299            &before_bytes,
300            &candidate,
301            &protected,
302        ) {
303            transform_reports.push(rolled_back(entry, tokens_before));
304            warnings.push(safety_downgrade_warning(entry.transform_id.as_str()));
305            continue;
306        }
307
308        bytes = candidate;
309        current_tokens = tokens_after_candidate;
310        transform_reports.push(TransformReport {
311            id: entry.transform_id.as_str().to_string(),
312            version: entry.version.to_string(),
313            tokens_before,
314            tokens_after: current_tokens,
315            saved_tokens: tokens_before.saturating_sub(current_tokens),
316            savings_ratio: ratio(tokens_before, current_tokens),
317            elapsed_micros: None,
318            status: TransformStatus::Applied,
319            skipped_reason: None,
320            warnings: Vec::new(),
321        });
322    }
323
324    let status = match target {
325        None => Status::BestEffort,
326        Some(t) if current_tokens <= t => Status::Compressed,
327        Some(_) => Status::BestEffort,
328    };
329
330    let mut report = CompressionReport::new(
331        original_tokens,
332        current_tokens,
333        estimator_info,
334        status,
335        mode_label(policy.mode).to_string(),
336        format_label(input.format).to_string(),
337        task_scope_label(policy.task_scope).to_string(),
338        transform_reports,
339        warnings,
340    );
341    report.budget = Some(BudgetReport {
342        target_tokens: target,
343        protected_floor: floor,
344        achieved_tokens: current_tokens,
345    });
346    report.retrieval = retrieval;
347    Ok(CompressionOutput { bytes, report })
348}
349
350fn apply_single_transform(
351    transform_id: TransformId,
352    bytes: &[u8],
353    policy: &CompressionPolicy,
354) -> Result<Vec<u8>, String> {
355    match transform_id {
356        TransformId::JsonMinify => transforms::json::minify_json(bytes).map_err(|e| e.to_string()),
357        TransformId::JsonFieldFold => {
358            transforms::json_fold::fold_json(bytes).map_err(|e| e.to_string())
359        }
360        TransformId::JsonValueDict => {
361            transforms::json_dict::dict_json(bytes).map_err(|e| e.to_string())
362        }
363        TransformId::SchemaCompaction => {
364            // ponytail: a fixed example cap for now; per-mode example counts are a future
365            // config knob (F-011 acceptance criteria only requires the count be configurable,
366            // not that Phase 2 ship distinct values per mode).
367            transforms::schema::compact_schema(bytes, 1).map_err(|e| e.to_string())
368        }
369        TransformId::LogFieldFold => {
370            let text = std::str::from_utf8(bytes).map_err(|e| e.to_string())?;
371            Ok(transforms::log_fold::fold_log(text).into_bytes())
372        }
373        TransformId::LogCompaction => {
374            let text = std::str::from_utf8(bytes).map_err(|e| e.to_string())?;
375            Ok(transforms::logs::compact(text, false).into_bytes())
376        }
377        TransformId::DiffCompaction => {
378            let text = std::str::from_utf8(bytes).map_err(|e| e.to_string())?;
379            let keep_line_bodies = policy.task_scope != TaskScope::ChangeSummary;
380            Ok(transforms::diff::compact_diff(text, keep_line_bodies).into_bytes())
381        }
382    }
383}
384
385fn validate_safety(
386    transform_id: TransformId,
387    format: InputFormat,
388    before: &[u8],
389    after: &[u8],
390    protected: &[Vec<u8>],
391) -> bool {
392    match transform_id {
393        // json_field_fold intentionally restructures JSON (arrays of objects -> columnar
394        // form), so key-order preservation does NOT apply. Its safety invariant is instead
395        // exact reversibility: unfolding the output must reproduce the input's data.
396        TransformId::JsonFieldFold => {
397            if !safety::json_still_valid(after) {
398                return false;
399            }
400            if !transforms::json_fold::round_trips(before, after) {
401                return false;
402            }
403        }
404        // json_value_dict replaces repeated values with dictionary references — also a
405        // reversible restructure, gated on exact round-trip reconstruction.
406        TransformId::JsonValueDict => {
407            if !safety::json_still_valid(after) {
408                return false;
409            }
410            if !transforms::json_dict::round_trips(before, after) {
411                return false;
412            }
413        }
414        // json_minify / schema_compaction on any JSON-family format: output must stay valid
415        // JSON with byte-for-byte key order preserved.
416        TransformId::JsonMinify | TransformId::SchemaCompaction => {
417            let is_json_format = matches!(
418                format,
419                InputFormat::OpenAiJson | InputFormat::AnthropicJson | InputFormat::Json
420            );
421            if is_json_format {
422                if !safety::json_still_valid(after) {
423                    return false;
424                }
425                if !safety::json_key_order_preserved(before, after) {
426                    return false;
427                }
428            }
429        }
430        // log_field_fold restructures templated log lines into a columnar form, so its safety
431        // invariant is exact reversibility: unfolding the output must reproduce the input bytes.
432        TransformId::LogFieldFold => {
433            if !transforms::log_fold::round_trips(before, after) {
434                return false;
435            }
436        }
437        TransformId::LogCompaction | TransformId::DiffCompaction => {}
438    }
439    safety::protected_segments_present(protected, after)
440}
441
442fn skipped(entry: &ModeEntry, tokens: usize, reason: SkippedReason) -> TransformReport {
443    skipped_at(entry.transform_id.as_str(), entry.version, tokens, reason)
444}
445
446fn skipped_at(id: &str, version: &str, tokens: usize, reason: SkippedReason) -> TransformReport {
447    TransformReport {
448        id: id.to_string(),
449        version: version.to_string(),
450        tokens_before: tokens,
451        tokens_after: tokens,
452        saved_tokens: 0,
453        savings_ratio: 0.0,
454        elapsed_micros: None,
455        status: TransformStatus::Skipped,
456        skipped_reason: Some(reason),
457        warnings: Vec::new(),
458    }
459}
460
461fn rolled_back(entry: &ModeEntry, tokens: usize) -> TransformReport {
462    TransformReport {
463        id: entry.transform_id.as_str().to_string(),
464        version: entry.version.to_string(),
465        tokens_before: tokens,
466        tokens_after: tokens,
467        saved_tokens: 0,
468        savings_ratio: 0.0,
469        elapsed_micros: None,
470        status: TransformStatus::RolledBack,
471        skipped_reason: None,
472        warnings: Vec::new(),
473    }
474}
475
476fn safety_downgrade_warning(transform_id: &str) -> Warning {
477    Warning {
478        code: WarningCode::SafetyDowngrade,
479        severity: Severity::Warn,
480        transform: Some(transform_id.to_string()),
481        message: format!(
482            "{transform_id} was rolled back: a safety invariant would have been violated"
483        ),
484    }
485}
486
487fn heuristic_budget_warning() -> Warning {
488    Warning {
489        code: WarningCode::HeuristicBudgetUsed,
490        severity: Severity::Info,
491        transform: None,
492        message: "token counts are heuristic estimates (~bytes/4), not exact".to_string(),
493    }
494}
495
496fn ratio(before: usize, after: usize) -> f64 {
497    if before == 0 {
498        0.0
499    } else {
500        before.saturating_sub(after) as f64 / before as f64
501    }
502}
503
504fn mode_label(mode: CompressionMode) -> &'static str {
505    match mode {
506        CompressionMode::Conservative => "conservative",
507        CompressionMode::Balanced => "balanced",
508        CompressionMode::Aggressive => "aggressive",
509    }
510}
511
512fn format_label(format: InputFormat) -> &'static str {
513    match format {
514        InputFormat::Auto => "auto",
515        InputFormat::OpenAiJson => "openai_json",
516        InputFormat::AnthropicJson => "anthropic_json",
517        InputFormat::Json => "json",
518        InputFormat::PlainText => "plain_text",
519        InputFormat::CommandOutput => "command_output",
520        InputFormat::GitDiff => "git_diff",
521    }
522}
523
524fn task_scope_label(scope: TaskScope) -> &'static str {
525    match scope {
526        TaskScope::All => "all",
527        TaskScope::General => "general",
528        TaskScope::CodeReview => "code_review",
529        TaskScope::ChangeSummary => "change_summary",
530        TaskScope::Debugging => "debugging",
531        TaskScope::Generation => "generation",
532        TaskScope::ApiOverview => "api_overview",
533        TaskScope::RetrievalQa => "retrieval_qa",
534        TaskScope::AgentHistory => "agent_history",
535    }
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use crate::budget::CompressionPolicy;
542
543    struct MockEstimator(usize);
544
545    impl TokenEstimator for MockEstimator {
546        fn info(&self) -> crate::report::EstimatorInfo {
547            crate::report::EstimatorInfo {
548                backend: "mock".to_string(),
549                model: Some("mock-1".to_string()),
550                is_exact: true,
551            }
552        }
553
554        fn count_bytes(&self, _bytes: &[u8]) -> usize {
555            self.0
556        }
557    }
558
559    #[test]
560    fn compress_with_estimator_accepts_a_mock_backend() {
561        let input = CompressionInput::plain_text(b"hello".to_vec());
562        let policy = CompressionPolicy::builder().build().unwrap();
563        let output = compress_with_estimator(input, &policy, &MockEstimator(42)).unwrap();
564        assert_eq!(output.report.original_tokens, 42);
565        assert_eq!(output.report.estimator.backend, "mock");
566    }
567
568    #[test]
569    fn passthrough_when_input_is_already_under_target() {
570        let input = CompressionInput::plain_text(b"hi".to_vec());
571        let policy = CompressionPolicy::builder()
572            .target_tokens(1_000)
573            .build()
574            .unwrap();
575        let output = compress_with_estimator(input.clone(), &policy, &MockEstimator(5)).unwrap();
576        assert_eq!(output.report.status, Status::Passthrough);
577        assert_eq!(output.bytes, input.bytes);
578    }
579
580    #[test]
581    fn unreachable_target_returns_best_effort_bytes_and_never_panics() {
582        let payload = serde_json::json!({
583            "messages": [{"role": "system", "content": "a fairly long system prompt here"}]
584        });
585        let input = CompressionInput::openai_json(serde_json::to_vec(&payload).unwrap());
586        let policy = CompressionPolicy::builder()
587            .target_tokens(1)
588            .build()
589            .unwrap();
590        let output = compress_with_estimator(input, &policy, &ByteHeuristicEstimator).unwrap();
591
592        assert_eq!(output.report.status, Status::UnreachableTarget);
593        assert!(!output.bytes.is_empty());
594        let budget = output.report.budget.expect("budget report populated");
595        assert_eq!(budget.target_tokens, Some(1));
596        assert!(budget.protected_floor > 1);
597        assert_eq!(budget.achieved_tokens, output.report.compressed_tokens);
598    }
599
600    #[test]
601    fn no_target_set_runs_pipeline_and_reports_estimator_provenance() {
602        let input = CompressionInput::plain_text(b"no target here".to_vec());
603        let policy = CompressionPolicy::builder().build().unwrap();
604        let output = compress_with_estimator(input, &policy, &ByteHeuristicEstimator).unwrap();
605        assert_eq!(output.report.estimator.backend, "heuristic");
606        assert_eq!(output.report.status, Status::BestEffort);
607    }
608
609    #[test]
610    fn public_compress_seam_never_panics_on_empty_input() {
611        let input = CompressionInput::plain_text(Vec::new());
612        let policy = CompressionPolicy::builder().build().unwrap();
613        let output = compress(input, &policy).unwrap();
614        assert_eq!(output.report.original_tokens, 0);
615    }
616
617    #[test]
618    fn json_minify_actually_applies_and_reduces_tokens_for_openai_json() {
619        let payload =
620            b"{\n  \"messages\": [\n    {\"role\": \"user\", \"content\": \"hi\"}\n  ]\n}".to_vec();
621        let input = CompressionInput::openai_json(payload);
622        let policy = CompressionPolicy::builder().build().unwrap();
623        let output = compress_with_estimator(input, &policy, &ByteHeuristicEstimator).unwrap();
624
625        let applied = output
626            .report
627            .transforms
628            .iter()
629            .find(|t| t.id == "json_minify")
630            .expect("json_minify report present");
631        assert_eq!(applied.status, TransformStatus::Applied);
632        assert!(applied.saved_tokens > 0 || applied.tokens_after <= applied.tokens_before);
633        assert!(serde_json::from_slice::<serde_json::Value>(&output.bytes).is_ok());
634    }
635
636    #[test]
637    fn json_data_transforms_never_regress_token_count() {
638        // The "exact-token chooser" property: each JSON-data stage (minify -> fold -> dict) is
639        // adopted only if it lowers the exact token count, so no input can come out larger —
640        // including the shapes that sink naive TOON/CSV-ization: ragged, scalar, already-compact.
641        let cases: &[&[u8]] = &[
642            br#"[{"a":1,"b":2},{"a":3,"b":4},{"a":5,"b":6}]"#, // foldable
643            br#"[{"a":1},{"b":2},{"c":3}]"#,                   // ragged, heterogeneous
644            br#"{"x":[1,2,3],"y":"already compact scalar"}"#,  // no repeated structure
645            br#"[1,2,3,4,5,6,7,8,9,10]"#,                      // scalars only
646            br#"{}"#,                                          // trivial
647        ];
648        for case in cases {
649            let input = CompressionInput::json(case.to_vec());
650            let policy = CompressionPolicy::builder().build().unwrap();
651            let output = compress_with_estimator(input, &policy, &ByteHeuristicEstimator).unwrap();
652            assert!(
653                output.report.compressed_tokens <= output.report.original_tokens,
654                "regressed on {}: {} -> {}",
655                String::from_utf8_lossy(case),
656                output.report.original_tokens,
657                output.report.compressed_tokens,
658            );
659            // and every adopted transform round-trips is guaranteed by the pipeline safety gate;
660            // here we just assert the output is still valid JSON.
661            assert!(serde_json::from_slice::<serde_json::Value>(&output.bytes).is_ok());
662        }
663    }
664
665    #[test]
666    fn secret_redaction_warning_always_present_when_redaction_runs() {
667        let input = CompressionInput::plain_text(b"nothing secret here".to_vec());
668        let policy = CompressionPolicy::builder().build().unwrap();
669        let output = compress_with_estimator(input, &policy, &ByteHeuristicEstimator).unwrap();
670        assert!(
671            output
672                .report
673                .warnings
674                .iter()
675                .any(|w| w.code == WarningCode::UnredactedContentPossible)
676        );
677    }
678
679    #[test]
680    fn secret_redaction_removes_a_fake_bearer_token_before_any_other_transform() {
681        let input = CompressionInput::plain_text(
682            b"Authorization: Bearer sk-abcdEFGH1234567890123456\nother text".to_vec(),
683        );
684        let policy = CompressionPolicy::builder().build().unwrap();
685        let output = compress_with_estimator(input, &policy, &ByteHeuristicEstimator).unwrap();
686        assert!(!contains(&output.bytes, b"sk-abcdEFGH1234567890123456"));
687    }
688
689    #[test]
690    fn log_compaction_applies_by_default_after_promotion() {
691        // log_compaction was promoted out of --experimental (roadmap.md Phase 5 Task 9,
692        // 2026-07-12): it now applies under the default Balanced mode with no --experimental
693        // flag needed, unlike diff_compaction below (which stays gated). Ten adjacent repeats
694        // of a realistic log line (not a two-byte "a") so the collapsed evidence marker is a
695        // genuine net token saving, not swamped by its own overhead.
696        let mut text = String::from("Starting server on port 8080\n");
697        for _ in 0..10 {
698            text.push_str("Connecting to database...\n");
699        }
700        text.push_str("Database connection established");
701        let input = CompressionInput::command_output(text.into_bytes());
702        let policy = CompressionPolicy::builder()
703            .task_scope(TaskScope::General)
704            .build()
705            .unwrap();
706        let output = compress_with_estimator(input, &policy, &ByteHeuristicEstimator).unwrap();
707        assert!(
708            output
709                .report
710                .transforms
711                .iter()
712                .any(|t| t.id == "log_compaction" && t.status == TransformStatus::Applied)
713        );
714    }
715
716    #[test]
717    fn diff_compaction_never_applies_without_experimental_flag() {
718        let input = CompressionInput::plain_text(
719            b"diff --git a/f.rs b/f.rs\n@@ -1,2 +1,2 @@\n-old\n+new\n context\n context\n context"
720                .to_vec(),
721        );
722        let policy = CompressionPolicy::builder()
723            .task_scope(TaskScope::CodeReview)
724            .build()
725            .unwrap();
726        let output = compress_with_estimator(input, &policy, &ByteHeuristicEstimator).unwrap();
727        assert!(
728            !output
729                .report
730                .transforms
731                .iter()
732                .any(|t| t.id == "diff_compaction" && t.status == TransformStatus::Applied)
733        );
734    }
735
736    fn contains(haystack: &[u8], needle: &[u8]) -> bool {
737        haystack.windows(needle.len()).any(|w| w == needle)
738    }
739
740    // `XDG_DATA_HOME` is process-global; serialize the store_originals tests below so parallel
741    // `cargo test` threads don't race each other's overrides (same pattern as
742    // `tokenfold-cli::config`'s `ENV_LOCK`).
743    static RETRIEVAL_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
744
745    fn lock_retrieval_env() -> std::sync::MutexGuard<'static, ()> {
746        RETRIEVAL_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
747    }
748
749    #[test]
750    fn store_originals_false_leaves_retrieval_report_absent() {
751        let input = CompressionInput::plain_text(b"anything".to_vec());
752        let policy = CompressionPolicy::builder().build().unwrap();
753        let output = compress_with_estimator(input, &policy, &ByteHeuristicEstimator).unwrap();
754        assert!(output.report.retrieval.is_none());
755    }
756
757    #[test]
758    fn store_originals_persists_full_payload_and_populates_retrieval_report() {
759        let _g = lock_retrieval_env();
760        let dir = std::env::temp_dir().join(format!(
761            "tokenfold_pipeline_test_store_originals_{}",
762            std::process::id()
763        ));
764        unsafe {
765            std::env::set_var("XDG_DATA_HOME", &dir);
766        }
767
768        let input = CompressionInput::plain_text(b"nothing secret in here at all".to_vec());
769        let policy = CompressionPolicy::builder()
770            .store_originals(true)
771            .retrieval_namespace("pipeline-test")
772            .build()
773            .unwrap();
774        let output =
775            compress_with_estimator(input.clone(), &policy, &ByteHeuristicEstimator).unwrap();
776
777        let retrieval = output.report.retrieval.expect("retrieval report populated");
778        assert_eq!(retrieval.marker_count, 1);
779        assert_eq!(retrieval.persisted_original_bytes, input.bytes.len());
780        assert_eq!(retrieval.skipped_original_bytes, 0);
781        assert_eq!(retrieval.store_namespace, "pipeline-test");
782        assert_eq!(retrieval.hash_algorithm, "sha256");
783        assert_eq!(
784            retrieval.ttl_seconds,
785            Some(crate::retrieval_store::DEFAULT_TTL_SECONDS)
786        );
787
788        let hash = crate::retrieval_store::hex_sha256(&input.bytes);
789        let store = crate::retrieval_store::RetrievalStore::default_filesystem();
790        assert_eq!(
791            store.retrieve(&hash, "pipeline-test"),
792            crate::retrieval_store::RetrievalOutcome::Found(input.bytes.clone())
793        );
794
795        unsafe {
796            std::env::remove_var("XDG_DATA_HOME");
797        }
798        std::fs::remove_dir_all(&dir).ok();
799    }
800
801    #[test]
802    fn store_originals_skips_secret_bearing_payloads_without_erroring_the_compression() {
803        let _g = lock_retrieval_env();
804        let dir = std::env::temp_dir().join(format!(
805            "tokenfold_pipeline_test_store_originals_secret_{}",
806            std::process::id()
807        ));
808        unsafe {
809            std::env::set_var("XDG_DATA_HOME", &dir);
810        }
811
812        let input =
813            CompressionInput::plain_text(b"AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE".to_vec());
814        let policy = CompressionPolicy::builder()
815            .store_originals(true)
816            .retrieval_namespace("pipeline-test")
817            .build()
818            .unwrap();
819        let output =
820            compress_with_estimator(input.clone(), &policy, &ByteHeuristicEstimator).unwrap();
821
822        let retrieval = output.report.retrieval.expect("retrieval report populated");
823        assert_eq!(retrieval.marker_count, 0);
824        assert_eq!(retrieval.persisted_original_bytes, 0);
825        assert_eq!(retrieval.skipped_original_bytes, input.bytes.len());
826
827        let hash = crate::retrieval_store::hex_sha256(&input.bytes);
828        let store = crate::retrieval_store::RetrievalStore::default_filesystem();
829        assert_eq!(
830            store.retrieve(&hash, "pipeline-test"),
831            crate::retrieval_store::RetrievalOutcome::Missing
832        );
833
834        unsafe {
835            std::env::remove_var("XDG_DATA_HOME");
836        }
837        std::fs::remove_dir_all(&dir).ok();
838    }
839}