Skip to main content

sqz_engine/
engine.rs

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