Skip to main content

sqz_engine/
engine.rs

1use std::path::Path;
2use std::sync::{Arc, Mutex};
3use std::time::Duration;
4
5use crate::ast_parser::AstParser;
6use crate::budget_tracker::{BudgetTracker, UsageReport};
7use crate::cache_manager::CacheManager;
8use crate::confidence_router::ConfidenceRouter;
9use crate::cost_calculator::{CostCalculator, SessionCostSummary};
10use crate::ctx_format::CtxFormat;
11use crate::error::{Result, SqzError};
12use crate::model_router::ModelRouter;
13use crate::pin_manager::PinManager;
14use crate::pipeline::CompressionPipeline;
15use crate::plugin_api::PluginLoader;
16use crate::preset::{Preset, PresetParser};
17use crate::session_store::{SessionStore, SessionSummary};
18use crate::terse_mode::TerseMode;
19use crate::types::{CompressedContent, PinEntry, Provenance, SessionId};
20use crate::verifier::Verifier;
21
22/// Top-level facade that wires all sqz_engine modules together.
23///
24/// # Concurrency design
25///
26/// `SqzEngine` is designed for single-threaded use on the main thread.
27/// The only cross-thread sharing happens during preset hot-reload: the
28/// file-watcher callback runs on a background thread and needs to update
29/// the preset, pipeline, and model router. These three fields are wrapped
30/// in `Arc<Mutex<>>` specifically for that purpose. All other fields are
31/// owned directly — no unnecessary synchronization.
32pub struct SqzEngine {
33    // --- Hot-reloadable state (shared with file-watcher thread) ---
34    preset: Arc<Mutex<Preset>>,
35    pipeline: Arc<Mutex<CompressionPipeline>>,
36    model_router: Arc<Mutex<ModelRouter>>,
37
38    // --- Single-owner state (no cross-thread sharing needed) ---
39    session_store: SessionStore,
40    cache_manager: CacheManager,
41    budget_tracker: BudgetTracker,
42    cost_calculator: CostCalculator,
43    ast_parser: AstParser,
44    terse_mode: TerseMode,
45    pin_manager: PinManager,
46    confidence_router: ConfidenceRouter,
47    _plugin_loader: PluginLoader,
48}
49
50impl SqzEngine {
51    /// Create a new engine with the default preset and a persistent session store.
52    ///
53    /// Sessions are stored in `~/.sqz/sessions.db` for cross-session continuity.
54    /// Falls back to a temp-file store if the home directory is unavailable.
55    pub fn new() -> Result<Self> {
56        let preset = Preset::default();
57        let store_path = Self::default_store_path();
58        Self::with_preset_and_store(preset, &store_path)
59    }
60
61    /// Resolve the default session store path.
62    ///
63    /// `SQZ_DB_PATH` overrides everything — set it to keep the database
64    /// (stats, dedup cache, sessions) per-project instead of global.
65    /// Relative values resolve against the process working directory, so
66    /// prefer absolute paths when hooks may run from subdirectories.
67    /// Otherwise `~/.sqz/sessions.db`, falling back to a temp-file path
68    /// if the home dir is unavailable.
69    fn default_store_path() -> std::path::PathBuf {
70        if let Some(custom) = Self::store_path_from_env(std::env::var("SQZ_DB_PATH").ok()) {
71            return custom;
72        }
73        if let Some(home) = dirs_next::home_dir() {
74            let sqz_dir = home.join(".sqz");
75            if std::fs::create_dir_all(&sqz_dir).is_ok() {
76                // Harden permissions on Unix: ~/.sqz/ contains session data
77                // and cached content that may include sensitive output.
78                #[cfg(unix)]
79                {
80                    use std::os::unix::fs::PermissionsExt;
81                    let _ = std::fs::set_permissions(
82                        &sqz_dir,
83                        std::fs::Permissions::from_mode(0o700),
84                    );
85                }
86                return sqz_dir.join("sessions.db");
87            }
88        }
89        // Fallback: temp dir with unique name
90        let dir = std::env::temp_dir();
91        dir.join(format!(
92            "sqz_session_{}_{}.db",
93            std::process::id(),
94            std::time::SystemTime::now()
95                .duration_since(std::time::UNIX_EPOCH)
96                .map(|d| d.as_nanos())
97                .unwrap_or(0)
98        ))
99    }
100
101    /// Turn an `SQZ_DB_PATH` value into a usable store path, creating the
102    /// parent directory (0700 on Unix, matching `~/.sqz`). Empty or
103    /// whitespace-only values are treated as unset.
104    fn store_path_from_env(value: Option<String>) -> Option<std::path::PathBuf> {
105        let raw = value?;
106        let trimmed = raw.trim();
107        if trimmed.is_empty() {
108            return None;
109        }
110        let path = std::path::PathBuf::from(trimmed);
111        if let Some(parent) = path.parent() {
112            if !parent.as_os_str().is_empty() && !parent.exists() {
113                // Harden only directories we create ourselves — never
114                // chmod a pre-existing directory the user pointed into.
115                if std::fs::create_dir_all(parent).is_ok() {
116                    #[cfg(unix)]
117                    {
118                        use std::os::unix::fs::PermissionsExt;
119                        let _ = std::fs::set_permissions(
120                            parent,
121                            std::fs::Permissions::from_mode(0o700),
122                        );
123                    }
124                }
125            }
126        }
127        Some(path)
128    }
129
130    /// Whether informational stderr lines should be suppressed.
131    ///
132    /// `SQZ_QUIET=1` silences the per-compression progress lines ("N/M
133    /// tokens", "dedup hit", "fallback: verifier confidence …"). Errors
134    /// still print. Used by `sqz discover --replay`, which compresses
135    /// thousands of outputs in one run, and available to users whose
136    /// hook setup surfaces stderr where it isn't wanted.
137    pub fn quiet_stderr() -> bool {
138        match std::env::var("SQZ_QUIET") {
139            Ok(v) => !v.is_empty() && v != "0",
140            Err(_) => false,
141        }
142    }
143
144    /// How long a dedup ref stays valid after the original was served.
145    ///
146    /// `SQZ_REF_TTL_SECS` overrides the 30-minute default. Clients that
147    /// compact context aggressively (or have no PreCompact hook) can set
148    /// this lower so refs stop pointing at content the model has already
149    /// dropped. Unparseable or empty values fall back to the default.
150    fn ref_ttl_from_env(value: Option<String>) -> Duration {
151        const DEFAULT: Duration = Duration::from_secs(30 * 60);
152        match value.as_deref().map(str::trim) {
153            Some(raw) if !raw.is_empty() => raw
154                .parse::<u64>()
155                .map(Duration::from_secs)
156                .unwrap_or(DEFAULT),
157            _ => DEFAULT,
158        }
159    }
160
161    /// Create with a custom preset and a file-backed session store.
162    ///
163    /// Opens a single SQLite connection for the session store. The cache
164    /// manager and pin manager share the same store via separate connections
165    /// (SQLite WAL mode supports concurrent readers).
166    pub fn with_preset_and_store(preset: Preset, store_path: &Path) -> Result<Self> {
167        let pipeline = CompressionPipeline::new(&preset);
168        let window_size = preset.budget.default_window_size;
169
170        // One connection per consumer. SQLite WAL mode handles concurrency.
171        let session_store = SessionStore::open_or_create(store_path)?;
172        let cache_store = SessionStore::open_or_create(store_path)?;
173        let pin_store = SessionStore::open_or_create(store_path)?;
174
175        Ok(SqzEngine {
176            preset: Arc::new(Mutex::new(preset.clone())),
177            pipeline: Arc::new(Mutex::new(pipeline)),
178            model_router: Arc::new(Mutex::new(ModelRouter::new(&preset))),
179            session_store,
180            cache_manager: CacheManager::with_ref_age_duration(
181                cache_store,
182                512 * 1024 * 1024,
183                Self::ref_ttl_from_env(std::env::var("SQZ_REF_TTL_SECS").ok()),
184            ),
185            budget_tracker: BudgetTracker::new(window_size, &preset),
186            cost_calculator: CostCalculator::with_defaults(),
187            ast_parser: AstParser::new(),
188            terse_mode: TerseMode,
189            pin_manager: PinManager::new(pin_store),
190            confidence_router: ConfidenceRouter::new(),
191            _plugin_loader: PluginLoader::new(Path::new("plugins")),
192        })
193    }
194
195    /// Compress input text using the current preset.
196    ///
197    /// Two-pass pipeline:
198    /// 1. Route to compression mode based on content entropy and risk patterns.
199    /// 2. Compress using the pipeline (safe preset for Safe mode, default otherwise).
200    /// 3. Verify invariants (error lines, JSON keys, diff hunks, etc.).
201    /// 4. If verification confidence is low, fall back to safe mode and re-compress.
202    pub fn compress(&self, input: &str) -> Result<CompressedContent> {
203        let preset = self.preset.lock()
204            .map_err(|_| SqzError::Other("preset lock poisoned".into()))?;
205        let pipeline = self.pipeline.lock()
206            .map_err(|_| SqzError::Other("pipeline lock poisoned".into()))?;
207        let ctx = crate::pipeline::SessionContext {
208            session_id: "engine".to_string(),
209        };
210
211        // Step 1: Route — check content risk before compressing
212        let mode = self.confidence_router.route(input);
213
214        // Step 2: If Safe mode, skip aggressive pipeline and go straight to safe compress
215        if mode == crate::confidence_router::CompressionMode::Safe {
216            // Silent by default: in the hook path every stderr line lands in
217            // the agent's context, and a passthrough has nothing to report.
218            if std::env::var_os("SQZ_VERBOSE").is_some() {
219                eprintln!("[sqz] safe mode: high-risk content passed through unchanged");
220            }
221            return self.compress_safe(input, &pipeline, &ctx);
222        }
223
224        // Step 3: Compress with the configured pipeline
225        let mut result = pipeline.compress(input, &ctx, &preset)?;
226
227        // Step 4: Verify invariants
228        let verify = Verifier::verify(input, &result.data);
229        let fallback = verify.fallback_triggered;
230        result.verify = Some(verify);
231
232        // Step 5: If verifier signals low confidence, re-compress with safe settings
233        if fallback && result.data != input {
234            if !Self::quiet_stderr() {
235                eprintln!("[sqz] fallback: verifier confidence {:.2} below threshold — re-compressing in safe mode",
236                    result.verify.as_ref().map(|v| v.confidence).unwrap_or(0.0));
237            }
238            let safe_result = self.compress_safe(input, &pipeline, &ctx)?;
239            return Ok(safe_result);
240        }
241
242        Ok(result)
243    }
244
245    /// Compress with dedup cache lookup.
246    ///
247    /// Unlike [`compress`], this method consults the persistent cache
248    /// (`~/.sqz/sessions.db`). On a hit with a fresh ref, it returns a
249    /// 13-token `§ref:HASH§` token instead of re-compressing. On a
250    /// near-duplicate, it returns a compact delta. On a cache miss, it
251    /// runs the full pipeline, stores the result, and returns it.
252    ///
253    /// This is the path the MCP `sqz_read_file` / `sqz_grep` /
254    /// `sqz_list_dir` tools use — repeat reads in the same session
255    /// (and across sessions if the DB survives) collapse to 13 tokens.
256    /// Reported on issue #12: the MCP file tools bypassed the cache so
257    /// dedup never fired, and users were getting 30%-range pipeline
258    /// compression instead of the 92% dedup path advertised in the
259    /// README.
260    pub fn compress_with_cache(&self, input: &str) -> Result<crate::cache_manager::CacheResult> {
261        let pipeline = self.pipeline.lock()
262            .map_err(|_| SqzError::Other("pipeline lock poisoned".into()))?;
263        // The `path` argument is informational only — cache keys are
264        // SHA-256 of the content bytes, not the path. Pass an empty
265        // path so we don't partition the cache by accidental file
266        // location differences (e.g. `./src/main.rs` vs
267        // `/abs/path/to/src/main.rs`).
268        self.cache_manager.get_or_compress(
269            std::path::Path::new(""),
270            input.as_bytes(),
271            &pipeline,
272        )
273    }
274
275    /// Lossless variant of [`compress_with_cache`] for the file-read MCP tools
276    /// (`sqz_read_file` / `sqz_grep` / `sqz_list_dir`): on a cache miss the
277    /// content is returned faithfully — no entropy truncation, pruning, or
278    /// re-encoding. The general `compress` tool keeps using
279    /// [`compress_with_cache`] for aggressive compression. See issue #32.
280    pub fn compress_with_cache_lossless(&self, input: &str) -> Result<crate::cache_manager::CacheResult> {
281        let pipeline = self.pipeline.lock()
282            .map_err(|_| SqzError::Other("pipeline lock poisoned".into()))?;
283        self.cache_manager.get_or_compress_lossless(
284            std::path::Path::new(""),
285            input.as_bytes(),
286            &pipeline,
287        )
288    }
289
290    /// Defensive compression: any input in, `CompressedContent` out, guaranteed.
291    ///
292    /// Unlike `compress()` which returns `Result`, this method never returns
293    /// an error. On any internal failure it returns the original input
294    /// unchanged with a 1.0 compression ratio. This makes it safe to call
295    /// from contexts where error handling is impractical (e.g. shell hooks,
296    /// browser extension bridges).
297    pub fn compress_or_passthrough(&self, input: &str) -> CompressedContent {
298        match self.compress(input) {
299            Ok(result) => result,
300            Err(_) => {
301                let tokens = (input.len() as u32 + 3) / 4;
302                CompressedContent {
303                    data: input.to_string(),
304                    tokens_compressed: tokens,
305                    tokens_original: tokens,
306                    stages_applied: vec![],
307                    compression_ratio: 1.0,
308                    provenance: crate::types::Provenance::default(),
309                    verify: None,
310                }
311            }
312        }
313    }
314
315    /// Compress with explicit mode override, bypassing the confidence router.
316    ///
317    /// - `CompressionMode::Safe` → safe pipeline only (ANSI strip + condense)
318    /// - `CompressionMode::Default` → standard pipeline
319    /// - `CompressionMode::Aggressive` → standard pipeline (aggressive preset TBD)
320    pub fn compress_with_mode(&self, input: &str, mode: crate::confidence_router::CompressionMode) -> Result<CompressedContent> {
321        let pipeline = self.pipeline.lock()
322            .map_err(|_| SqzError::Other("pipeline lock poisoned".into()))?;
323        let ctx = crate::pipeline::SessionContext {
324            session_id: "engine".to_string(),
325        };
326
327        match mode {
328            crate::confidence_router::CompressionMode::Safe => {
329                self.compress_safe(input, &pipeline, &ctx)
330            }
331            _ => {
332                // Default and Aggressive: run normal pipeline + verify
333                drop(pipeline); // release lock before calling compress()
334                self.compress(input)
335            }
336        }
337    }
338
339    /// Safe-mode compression: minimal transforms only (ANSI strip + condense).
340    fn compress_safe(
341        &self,
342        input: &str,
343        pipeline: &crate::pipeline::CompressionPipeline,
344        ctx: &crate::pipeline::SessionContext,
345    ) -> Result<CompressedContent> {
346        use crate::preset::{
347            CompressionConfig, CondenseConfig, CustomTransformsConfig, BudgetConfig,
348            ModelConfig, PresetMeta, TerseModeConfig, TerseLevel, ToolSelectionConfig,
349        };
350
351        let safe_preset = Preset {
352            preset: PresetMeta {
353                name: "safe".to_string(),
354                version: "1.0".to_string(),
355                description: "Safe fallback — minimal compression".to_string(),
356            },
357            compression: CompressionConfig {
358                stages: vec!["condense".to_string()],
359                keep_fields: None,
360                strip_fields: None,
361                condense: Some(CondenseConfig { enabled: true, max_repeated_lines: 3 }),
362                git_diff_fold: None,
363                strip_nulls: None,
364                flatten: None,
365                truncate_strings: None,
366                collapse_arrays: None,
367                custom_transforms: Some(CustomTransformsConfig { enabled: false }),
368            },
369            tool_selection: ToolSelectionConfig {
370                max_tools: 5,
371                similarity_threshold: 0.7,
372                default_tools: vec![],
373            },
374            budget: BudgetConfig {
375                warning_threshold: 0.70,
376                ceiling_threshold: 0.85,
377                default_window_size: 200_000,
378                agents: Default::default(),
379            },
380            terse_mode: TerseModeConfig { enabled: false, level: TerseLevel::Moderate },
381            model: ModelConfig {
382                family: "anthropic".to_string(),
383                primary: String::new(),
384                local: String::new(),
385                complexity_threshold: 0.4,
386                pricing: None,
387            },
388        };
389
390        let mut result = pipeline.compress(input, ctx, &safe_preset)?;
391        let verify = Verifier::verify(input, &result.data);
392        result.verify = Some(verify);
393        result.provenance = Provenance {
394            label: Some("safe-fallback".to_string()),
395            ..Default::default()
396        };
397        Ok(result)
398    }
399
400    /// Compress with explicit provenance metadata attached to the result.
401    pub fn compress_with_provenance(
402        &self,
403        input: &str,
404        provenance: Provenance,
405    ) -> Result<CompressedContent> {
406        let mut result = self.compress(input)?;
407        result.provenance = provenance;
408        Ok(result)
409    }
410
411    /// Export a session to CTX format.
412    pub fn export_ctx(&self, session_id: &str) -> Result<String> {
413        let session = self.session_store.load_session(session_id.to_string())?;
414        CtxFormat::serialize(&session)
415    }
416
417    /// Import a CTX string and save as a new session.
418    pub fn import_ctx(&self, ctx: &str) -> Result<SessionId> {
419        let session = CtxFormat::deserialize(ctx)?;
420        self.session_store.save_session(&session)
421    }
422
423    /// Pin a conversation turn.
424    pub fn pin(&self, session_id: &str, turn_index: usize, reason: &str, tokens: u32) -> Result<PinEntry> {
425        self.pin_manager.pin(session_id, turn_index, reason, tokens)
426    }
427
428    /// Unpin a conversation turn.
429    pub fn unpin(&self, session_id: &str, turn_index: usize) -> Result<()> {
430        self.pin_manager.unpin(session_id, turn_index)
431    }
432
433    /// Search sessions by keyword.
434    pub fn search_sessions(&self, query: &str) -> Result<Vec<SessionSummary>> {
435        self.session_store.search(query)
436    }
437
438    /// Get usage report for an agent.
439    pub fn usage_report(&self, agent_id: &str) -> UsageReport {
440        self.budget_tracker.usage_report(agent_id.to_string())
441    }
442
443    /// Get cost summary for a session.
444    pub fn cost_summary(&self, session_id: &str) -> Result<SessionCostSummary> {
445        let session = self.session_store.load_session(session_id.to_string())?;
446        Ok(self.cost_calculator.session_summary(&session))
447    }
448
449    /// Reload the preset from a TOML string (hot-reload support).
450    pub fn reload_preset(&mut self, toml: &str) -> Result<()> {
451        let new_preset = PresetParser::parse(toml)?;
452        if let Ok(mut pipeline) = self.pipeline.lock() {
453            pipeline.reload_preset(&new_preset)?;
454        }
455        if let Ok(mut router) = self.model_router.lock() {
456            *router = ModelRouter::new(&new_preset);
457        }
458        if let Ok(mut preset) = self.preset.lock() {
459            *preset = new_preset;
460        }
461        Ok(())
462    }
463
464    /// Spawn a background thread that watches `path` for preset file changes.
465    ///
466    /// Only the preset, pipeline, and model_router are shared with the watcher
467    /// thread (via `Arc<Mutex<>>`). All other engine state stays on the main thread.
468    pub fn watch_preset_file(&self, path: &Path) -> Result<notify::RecommendedWatcher> {
469        use notify::{Event, EventKind, RecursiveMode, Watcher};
470
471        let preset_arc = Arc::clone(&self.preset);
472        let pipeline_arc = Arc::clone(&self.pipeline);
473        let router_arc = Arc::clone(&self.model_router);
474        let watched_path = path.to_owned();
475
476        let mut watcher = notify::recommended_watcher(move |res: notify::Result<Event>| {
477            if let Ok(event) = res {
478                if matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_)) {
479                    match std::fs::read_to_string(&watched_path) {
480                        Ok(toml_str) => match PresetParser::parse(&toml_str) {
481                            Ok(new_preset) => {
482                                if let Ok(mut p) = pipeline_arc.lock() {
483                                    let _ = p.reload_preset(&new_preset);
484                                }
485                                if let Ok(mut r) = router_arc.lock() {
486                                    *r = ModelRouter::new(&new_preset);
487                                }
488                                if let Ok(mut pr) = preset_arc.lock() {
489                                    *pr = new_preset;
490                                }
491                            }
492                            Err(e) => eprintln!("[sqz] invalid preset: {e}"),
493                        },
494                        Err(e) => eprintln!("[sqz] preset read error: {e}"),
495                    }
496                }
497            }
498        })
499        .map_err(|e| SqzError::Other(format!("watcher error: {e}")))?;
500
501        watcher
502            .watch(path, RecursiveMode::NonRecursive)
503            .map_err(|e| SqzError::Other(format!("watch error: {e}")))?;
504
505        Ok(watcher)
506    }
507
508    /// Access the underlying `SessionStore`.
509    pub fn session_store(&self) -> &SessionStore {
510        &self.session_store
511    }
512
513    /// Access the `CacheManager` for persistent dedup.
514    pub fn cache_manager(&self) -> &CacheManager {
515        &self.cache_manager
516    }
517
518    /// Access the `AstParser`.
519    pub fn ast_parser(&self) -> &AstParser {
520        &self.ast_parser
521    }
522
523    /// Access the `TerseMode` helper.
524    pub fn terse_mode(&self) -> &TerseMode {
525        &self.terse_mode
526    }
527
528    /// Reorder context sections using the LITM positioner to mitigate
529    /// the "Lost In The Middle" attention bias in long-context models.
530    ///
531    /// Places highest-priority sections at the beginning and end of the
532    /// context window, lowest-priority in the middle.
533    pub fn reorder_context(
534        &self,
535        sections: &mut Vec<crate::litm_positioner::ContextSection>,
536        strategy: crate::litm_positioner::LitmStrategy,
537    ) {
538        let positioner = crate::litm_positioner::LitmPositioner::new(strategy);
539        positioner.reorder(sections);
540    }
541
542    /// Route content to the appropriate compression mode based on entropy
543    /// and risk pattern analysis.
544    pub fn route_compression_mode(&self, content: &str) -> crate::confidence_router::CompressionMode {
545        self.confidence_router.route(content)
546    }
547
548    /// Count tokens the way `compress` counts `tokens_original`.
549    pub fn count_tokens(&self, text: &str) -> u32 {
550        let preset = self.preset.lock().unwrap();
551        self.pipeline.lock().unwrap().count_tokens(text, &preset)
552    }
553}
554
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use crate::types::{BudgetState, CorrectionLog, ModelFamily, SessionState};
560    use chrono::Utc;
561    use std::path::PathBuf;
562
563    fn make_session(id: &str) -> SessionState {
564        let now = Utc::now();
565        SessionState {
566            id: id.to_string(),
567            project_dir: PathBuf::from("/tmp/test"),
568            conversation: vec![],
569            corrections: CorrectionLog::default(),
570            pins: vec![],
571            learnings: vec![],
572            compressed_summary: "test session".to_string(),
573            budget: BudgetState {
574                window_size: 200_000,
575                consumed: 0,
576                pinned: 0,
577                model_family: ModelFamily::AnthropicClaude,
578            },
579            tool_usage: vec![],
580            created_at: now,
581            updated_at: now,
582        }
583    }
584
585    #[test]
586    fn test_engine_new() {
587        let engine = SqzEngine::new();
588        assert!(engine.is_ok(), "SqzEngine::new() should succeed");
589    }
590
591    #[test]
592    fn test_compress_or_passthrough_returns_result_on_valid_input() {
593        let engine = SqzEngine::new().unwrap();
594        let result = engine.compress_or_passthrough("hello world");
595        assert_eq!(result.data, "hello world");
596        assert!(result.tokens_original > 0);
597    }
598
599    #[test]
600    fn test_compress_or_passthrough_never_panics_on_empty() {
601        let engine = SqzEngine::new().unwrap();
602        let result = engine.compress_or_passthrough("");
603        assert_eq!(result.data, "");
604        assert_eq!(result.compression_ratio, 1.0);
605    }
606
607    #[test]
608    fn test_compress_or_passthrough_handles_json() {
609        let engine = SqzEngine::new().unwrap();
610        let result = engine.compress_or_passthrough(r#"{"key":"value"}"#);
611        // Should compress successfully — data may be TOON-encoded
612        assert!(!result.data.is_empty());
613    }
614
615    #[test]
616    fn test_compress_or_passthrough_handles_binary_garbage() {
617        let engine = SqzEngine::new().unwrap();
618        // Feed it something weird — should never panic, always return something
619        let garbage = "\x00\x01\x02\x7f invalid control chars \t\n\r";
620        let result = engine.compress_or_passthrough(garbage);
621        assert!(!result.data.is_empty());
622    }
623
624    #[test]
625    fn test_compress_plain_text() {
626        let engine = SqzEngine::new().unwrap();
627        let result = engine.compress("hello world");
628        assert!(result.is_ok());
629        assert_eq!(result.unwrap().data, "hello world");
630    }
631
632    #[test]
633    fn test_compress_json_applies_toon() {
634        let engine = SqzEngine::new().unwrap();
635        let result = engine.compress(r#"{"name":"Alice","age":30}"#).unwrap();
636        assert!(result.data.starts_with("TOON:"), "JSON should be TOON-encoded");
637    }
638
639    #[test]
640    fn test_export_import_ctx_round_trip() {
641        let dir = tempfile::tempdir().unwrap();
642        let store_path = dir.path().join("store.db");
643        let engine = SqzEngine::with_preset_and_store(Preset::default(), &store_path).unwrap();
644
645        let session = make_session("sess-rt");
646        engine.session_store().save_session(&session).unwrap();
647
648        let ctx = engine.export_ctx("sess-rt").unwrap();
649        let imported_id = engine.import_ctx(&ctx).unwrap();
650        assert_eq!(imported_id, "sess-rt");
651    }
652
653    #[test]
654    fn test_search_sessions() {
655        let dir = tempfile::tempdir().unwrap();
656        let store_path = dir.path().join("store.db");
657        let engine = SqzEngine::with_preset_and_store(Preset::default(), &store_path).unwrap();
658
659        let mut session = make_session("sess-search");
660        session.compressed_summary = "authentication refactor".to_string();
661        engine.session_store().save_session(&session).unwrap();
662
663        let results = engine.search_sessions("authentication").unwrap();
664        assert_eq!(results.len(), 1);
665        assert_eq!(results[0].id, "sess-search");
666    }
667
668    #[test]
669    fn test_usage_report_starts_at_zero() {
670        let engine = SqzEngine::new().unwrap();
671        let report = engine.usage_report("default");
672        assert_eq!(report.consumed, 0);
673        assert_eq!(report.available, report.allocated);
674    }
675
676    #[test]
677    fn test_cost_summary() {
678        let dir = tempfile::tempdir().unwrap();
679        let store_path = dir.path().join("store.db");
680        let engine = SqzEngine::with_preset_and_store(Preset::default(), &store_path).unwrap();
681
682        let session = make_session("sess-cost");
683        engine.session_store().save_session(&session).unwrap();
684
685        let summary = engine.cost_summary("sess-cost").unwrap();
686        assert_eq!(summary.total_tokens, 0);
687        assert!((summary.total_usd - 0.0).abs() < f64::EPSILON);
688    }
689
690    #[test]
691    fn test_reload_preset_updates_state() {
692        let mut engine = SqzEngine::new().unwrap();
693        let toml = r#"
694[preset]
695name = "reloaded"
696version = "2.0"
697
698[compression]
699stages = []
700
701[tool_selection]
702max_tools = 5
703similarity_threshold = 0.7
704
705[budget]
706warning_threshold = 0.70
707ceiling_threshold = 0.85
708default_window_size = 200000
709
710[terse_mode]
711enabled = false
712level = "moderate"
713
714[model]
715family = "anthropic"
716primary = "claude-sonnet-4-20250514"
717complexity_threshold = 0.4
718"#;
719        assert!(engine.reload_preset(toml).is_ok());
720        // Verify the preset was actually updated
721        let preset = engine.preset.lock().unwrap();
722        assert_eq!(preset.preset.name, "reloaded");
723    }
724
725    #[test]
726    fn test_reload_invalid_preset_returns_error() {
727        let mut engine = SqzEngine::new().unwrap();
728        let result = engine.reload_preset("not valid toml [[[");
729        assert!(result.is_err(), "invalid TOML should return error");
730    }
731
732    #[test]
733    fn test_export_nonexistent_session_returns_error() {
734        let engine = SqzEngine::new().unwrap();
735        let result = engine.export_ctx("does-not-exist");
736        assert!(result.is_err());
737    }
738
739    #[test]
740    fn test_import_invalid_ctx_returns_error() {
741        let engine = SqzEngine::new().unwrap();
742        let result = engine.import_ctx("not valid json {{{");
743        assert!(result.is_err());
744    }
745
746    #[test]
747    fn store_path_env_override_used_when_set() {
748        let dir = tempfile::tempdir().unwrap();
749        let target = dir.path().join("nested").join("project.db");
750        let resolved =
751            SqzEngine::store_path_from_env(Some(target.to_string_lossy().to_string()));
752        assert_eq!(resolved, Some(target.clone()));
753        // Parent directory is created so SQLite can open the file.
754        assert!(target.parent().unwrap().exists());
755    }
756
757    #[test]
758    fn store_path_env_ignores_empty_values() {
759        assert_eq!(SqzEngine::store_path_from_env(None), None);
760        assert_eq!(SqzEngine::store_path_from_env(Some(String::new())), None);
761        assert_eq!(SqzEngine::store_path_from_env(Some("   ".to_string())), None);
762    }
763
764    #[test]
765    fn store_path_env_leaves_existing_parent_permissions_alone() {
766        let dir = tempfile::tempdir().unwrap();
767        #[cfg(unix)]
768        {
769            use std::os::unix::fs::PermissionsExt;
770            std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755))
771                .unwrap();
772        }
773        let target = dir.path().join("sessions.db");
774        let resolved =
775            SqzEngine::store_path_from_env(Some(target.to_string_lossy().to_string()));
776        assert_eq!(resolved, Some(target));
777        #[cfg(unix)]
778        {
779            use std::os::unix::fs::PermissionsExt;
780            let mode = std::fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777;
781            assert_eq!(mode, 0o755, "pre-existing parent must not be chmodded");
782        }
783    }
784
785    #[test]
786    fn ref_ttl_env_parses_seconds_and_falls_back() {
787        use std::time::Duration;
788        // Unset, empty, whitespace, garbage → 30-minute default.
789        assert_eq!(SqzEngine::ref_ttl_from_env(None), Duration::from_secs(1800));
790        assert_eq!(
791            SqzEngine::ref_ttl_from_env(Some(String::new())),
792            Duration::from_secs(1800)
793        );
794        assert_eq!(
795            SqzEngine::ref_ttl_from_env(Some("  ".into())),
796            Duration::from_secs(1800)
797        );
798        assert_eq!(
799            SqzEngine::ref_ttl_from_env(Some("ten".into())),
800            Duration::from_secs(1800)
801        );
802        assert_eq!(
803            SqzEngine::ref_ttl_from_env(Some("-5".into())),
804            Duration::from_secs(1800)
805        );
806        // Valid values are honored, including 0 (disable refs entirely).
807        assert_eq!(
808            SqzEngine::ref_ttl_from_env(Some("300".into())),
809            Duration::from_secs(300)
810        );
811        assert_eq!(
812            SqzEngine::ref_ttl_from_env(Some(" 60 ".into())),
813            Duration::from_secs(60)
814        );
815        assert_eq!(
816            SqzEngine::ref_ttl_from_env(Some("0".into())),
817            Duration::ZERO
818        );
819    }
820
821    #[test]
822    fn engine_with_custom_store_path_round_trips() {
823        let dir = tempfile::tempdir().unwrap();
824        let db = dir.path().join("proj").join("sessions.db");
825        let resolved = SqzEngine::store_path_from_env(Some(db.to_string_lossy().to_string()))
826            .unwrap();
827        let engine = SqzEngine::with_preset_and_store(Preset::default(), &resolved).unwrap();
828        engine
829            .session_store()
830            .log_compression(100, 40, &[], "test")
831            .unwrap();
832        assert!(db.exists(), "database file must land at the custom path");
833        let stats = engine.session_store().compression_stats().unwrap();
834        assert_eq!(stats.total_compressions, 1);
835    }
836}