envision 0.17.0

A ratatui framework for collaborative TUI development with headless testing support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
//! Snapshot testing support.
//!
//! # Golden-file snapshot pattern
//!
//! Envision keeps snapshot testing dependency-light: a render produces
//! a string, and you compare it against a fixture on disk. The recipe
//! below provides two functions — `update_golden` writes the fixture
//! unconditionally, `assert_matches_golden` reads-and-compares and
//! panics on mismatch with a unified diff.
//!
//! Typical workflow: invoke `update_golden` once (often gated by an
//! `UPDATE_GOLDEN` env check at the call site) to capture the expected
//! output, then call `assert_matches_golden` from the test body to
//! verify on subsequent runs.
//!
//! ```rust
//! use std::fs;
//! use std::path::Path;
//!
//! fn update_golden(path: &Path, content: &str) {
//!     if let Some(parent) = path.parent() {
//!         fs::create_dir_all(parent).unwrap();
//!     }
//!     fs::write(path, content).unwrap();
//! }
//!
//! fn assert_matches_golden(path: &Path, actual: &str) {
//!     let expected = fs::read_to_string(path).unwrap_or_else(|e| {
//!         panic!(
//!             "golden fixture missing at {}: {} (run with UPDATE_GOLDEN=1 to create)",
//!             path.display(),
//!             e,
//!         )
//!     });
//!     if expected != actual {
//!         panic!(
//!             "snapshot mismatch at {}:\n{}",
//!             path.display(),
//!             unified_diff(&expected, actual),
//!         );
//!     }
//! }
//!
//! fn unified_diff(expected: &str, actual: &str) -> String {
//!     let mut out = String::new();
//!     let e: Vec<&str> = expected.lines().collect();
//!     let a: Vec<&str> = actual.lines().collect();
//!     for i in 0..e.len().max(a.len()) {
//!         match (e.get(i), a.get(i)) {
//!             (Some(l), Some(r)) if l == r => out.push_str(&format!("  {l}\n")),
//!             (Some(l), Some(r)) => {
//!                 out.push_str(&format!("- {l}\n"));
//!                 out.push_str(&format!("+ {r}\n"));
//!             }
//!             (Some(l), None) => out.push_str(&format!("- {l}\n")),
//!             (None, Some(r)) => out.push_str(&format!("+ {r}\n")),
//!             (None, None) => {}
//!         }
//!     }
//!     out
//! }
//!
//! // End-to-end demo using a tempdir so the doc-test is
//! // self-contained.
//! let tmp = tempfile::tempdir().unwrap();
//! let path = tmp.path().join("golden.txt");
//! let rendered = "row 1\nrow 2\nrow 3\n";
//!
//! // First run: capture the fixture.
//! update_golden(&path, rendered);
//!
//! // Subsequent runs: assert match.
//! assert_matches_golden(&path, rendered);
//! ```
//!
//! ## Real-world call-site sketch
//!
//! ```ignore
//! let path = Path::new("tests/golden/dashboard.txt");
//! let actual = harness.snapshot_plain();
//!
//! if std::env::var("UPDATE_GOLDEN").is_ok() {
//!     update_golden(path, &actual);
//! } else {
//!     assert_matches_golden(path, &actual);
//! }
//! ```
//!
//! ## When to upgrade
//!
//! For richer diffs, review tooling (`cargo insta review`), and
//! parallel test isolation, switch to the [`insta`](https://docs.rs/insta)
//! crate. envision's own snapshot tests use `insta` internally; the
//! pattern above is offered as a starting point for downstream
//! consumers who want zero new dependencies.

use std::path::Path;

use crate::error;

use crate::annotation::AnnotationRegistry;
use crate::backend::FrameSnapshot;

/// Format for snapshot output.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SnapshotFormat {
    /// Plain text (screen content only)
    #[default]
    Plain,

    /// ANSI-colored text
    Ansi,

    /// JSON with full metadata
    #[cfg(feature = "serialization")]
    Json,

    /// JSON (pretty-printed)
    #[cfg(feature = "serialization")]
    JsonPretty,
}

/// A complete snapshot of UI state.
///
/// Includes both the rendered frame and annotation data.
#[derive(Clone, Debug)]
#[cfg_attr(
    feature = "serialization",
    derive(serde::Serialize, serde::Deserialize)
)]
pub struct Snapshot {
    /// The captured frame data
    pub frame: FrameSnapshot,

    /// Annotations for this frame
    pub annotations: AnnotationRegistry,
}

impl Snapshot {
    /// Creates a new snapshot.
    pub fn new(frame: FrameSnapshot, annotations: AnnotationRegistry) -> Self {
        Self { frame, annotations }
    }

    /// Returns the plain text representation.
    pub fn to_plain(&self) -> String {
        self.frame.to_plain()
    }

    /// Returns the ANSI-colored representation.
    pub fn to_ansi(&self) -> String {
        self.frame.to_ansi()
    }

    /// Returns the JSON representation.
    ///
    /// # Errors
    ///
    /// Returns an error if the snapshot cannot be serialized to JSON.
    #[cfg(feature = "serialization")]
    pub fn to_json(&self) -> serde_json::Result<String> {
        serde_json::to_string(self)
    }

    /// Returns the pretty-printed JSON representation.
    ///
    /// # Errors
    ///
    /// Returns an error if the snapshot cannot be serialized to JSON.
    #[cfg(feature = "serialization")]
    pub fn to_json_pretty(&self) -> serde_json::Result<String> {
        serde_json::to_string_pretty(self)
    }

    /// Formats the snapshot according to the specified format.
    pub fn format(&self, format: SnapshotFormat) -> String {
        match format {
            SnapshotFormat::Plain => self.to_plain(),
            SnapshotFormat::Ansi => self.to_ansi(),
            #[cfg(feature = "serialization")]
            SnapshotFormat::Json => self.to_json().unwrap_or_default(),
            #[cfg(feature = "serialization")]
            SnapshotFormat::JsonPretty => self.to_json_pretty().unwrap_or_default(),
        }
    }

    /// Writes the snapshot to a file.
    ///
    /// # Errors
    ///
    /// Returns an error if writing the formatted snapshot content to the
    /// file system fails.
    pub fn write_to_file(
        &self,
        path: impl AsRef<Path>,
        format: SnapshotFormat,
    ) -> error::Result<()> {
        let content = self.format(format);
        Ok(std::fs::write(path, content)?)
    }

    /// Loads a snapshot from a JSON file.
    ///
    /// # Errors
    ///
    /// Returns an error if the file cannot be read or if its contents
    /// cannot be deserialized as a valid JSON snapshot.
    #[cfg(feature = "serialization")]
    pub fn load_from_file(path: impl AsRef<Path>) -> error::Result<Self> {
        let content = std::fs::read_to_string(path)?;
        serde_json::from_str(&content)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e).into())
    }

    /// Compares this snapshot to another.
    pub fn diff(&self, other: &Snapshot) -> SnapshotDiff {
        SnapshotDiff::compute(self, other)
    }

    /// Returns true if this snapshot matches another exactly.
    pub fn matches(&self, other: &Snapshot) -> bool {
        self.to_plain() == other.to_plain()
    }

    /// Returns a formatted tree of annotations.
    pub fn annotation_tree(&self) -> String {
        self.annotations.format_tree()
    }

    /// Returns the number of annotations.
    pub fn annotation_count(&self) -> usize {
        self.annotations.len()
    }
}

/// Difference between two snapshots.
#[derive(Debug, Clone)]
pub struct SnapshotDiff {
    /// Lines that differ
    pub changed_lines: Vec<LineDiff>,

    /// Whether the annotations differ
    pub annotations_differ: bool,

    /// Number of changed lines
    pub changes: usize,
}

/// A single line difference.
#[derive(Debug, Clone)]
pub struct LineDiff {
    /// Line number (0-indexed)
    pub line: usize,

    /// Content in the first snapshot
    pub left: String,

    /// Content in the second snapshot
    pub right: String,
}

impl SnapshotDiff {
    /// Computes the diff between two snapshots.
    pub fn compute(left: &Snapshot, right: &Snapshot) -> Self {
        let left_plain = left.to_plain();
        let right_plain = right.to_plain();

        let left_lines: Vec<&str> = left_plain.lines().collect();
        let right_lines: Vec<&str> = right_plain.lines().collect();

        let max_lines = left_lines.len().max(right_lines.len());
        let mut changed_lines = Vec::new();

        for i in 0..max_lines {
            let l = left_lines.get(i).copied().unwrap_or("");
            let r = right_lines.get(i).copied().unwrap_or("");

            if l != r {
                changed_lines.push(LineDiff {
                    line: i,
                    left: l.to_string(),
                    right: r.to_string(),
                });
            }
        }

        let annotations_differ = left.annotations.format_tree() != right.annotations.format_tree();

        Self {
            changes: changed_lines.len(),
            changed_lines,
            annotations_differ,
        }
    }

    /// Returns true if the snapshots are identical.
    pub fn is_empty(&self) -> bool {
        self.changes == 0 && !self.annotations_differ
    }

    /// Formats the diff for display.
    pub fn format(&self) -> String {
        let mut output = String::new();

        if self.changed_lines.is_empty() && !self.annotations_differ {
            output.push_str("No differences\n");
            return output;
        }

        if !self.changed_lines.is_empty() {
            output.push_str(&format!("Changed lines ({}):\n", self.changes));
            for diff in &self.changed_lines {
                output.push_str(&format!("  Line {}:\n", diff.line + 1));
                output.push_str(&format!("    - {}\n", diff.left));
                output.push_str(&format!("    + {}\n", diff.right));
            }
        }

        if self.annotations_differ {
            output.push_str("Annotations differ\n");
        }

        output
    }
}

/// Asserts that two snapshots match.
///
/// # Panics
///
/// Panics with a diff if the snapshots differ.
pub fn assert_snapshot_eq(left: &Snapshot, right: &Snapshot) {
    let diff = left.diff(right);
    if !diff.is_empty() {
        panic!("Snapshots differ:\n{}", diff.format());
    }
}

/// Asserts that a snapshot matches an expected string.
///
/// # Panics
///
/// Panics if the snapshot's plain text doesn't match.
pub fn assert_snapshot_text(snapshot: &Snapshot, expected: &str) {
    let actual = snapshot.to_plain();
    if actual != expected {
        panic!(
            "Snapshot text differs:\n\nExpected:\n{}\n\nActual:\n{}",
            expected, actual
        );
    }
}

/// Helper for snapshot testing with file storage.
#[derive(Debug)]
pub struct SnapshotTest {
    /// Directory for snapshot files
    pub snapshot_dir: std::path::PathBuf,

    /// Format for snapshot files
    pub format: SnapshotFormat,

    /// Whether to update snapshots
    pub update: bool,
}

impl SnapshotTest {
    /// Creates a new snapshot test helper.
    pub fn new(snapshot_dir: impl AsRef<Path>) -> Self {
        Self {
            snapshot_dir: snapshot_dir.as_ref().to_path_buf(),
            format: SnapshotFormat::Plain,
            update: false,
        }
    }

    /// Sets the snapshot format.
    pub fn with_format(mut self, format: SnapshotFormat) -> Self {
        self.format = format;
        self
    }

    /// Enables update mode (overwrites existing snapshots).
    pub fn with_update(mut self, update: bool) -> Self {
        self.update = update;
        self
    }

    /// Returns the path for a snapshot file.
    pub fn snapshot_path(&self, name: &str) -> std::path::PathBuf {
        let ext = match self.format {
            SnapshotFormat::Plain => "txt",
            SnapshotFormat::Ansi => "ansi",
            #[cfg(feature = "serialization")]
            SnapshotFormat::Json | SnapshotFormat::JsonPretty => "json",
        };
        self.snapshot_dir.join(format!("{}.{}", name, ext))
    }

    /// Asserts that a snapshot matches the stored version.
    ///
    /// If update mode is enabled, overwrites the stored version.
    ///
    /// # Errors
    ///
    /// Returns an error if the snapshot directory cannot be created, if
    /// reading or writing snapshot files fails, or if the snapshot content
    /// does not match the stored version.
    pub fn assert(&self, name: &str, snapshot: &Snapshot) -> error::Result<()> {
        let path = self.snapshot_path(name);

        if self.update || !path.exists() {
            std::fs::create_dir_all(&self.snapshot_dir)?;
            snapshot.write_to_file(&path, self.format)?;
            return Ok(());
        }

        let expected = std::fs::read_to_string(&path)?;
        let actual = snapshot.format(self.format);

        if actual != expected {
            // Write actual to a .new file for comparison
            let new_path = path.with_extension(format!(
                "{}.new",
                path.extension().unwrap_or_default().to_string_lossy()
            ));
            std::fs::write(&new_path, &actual)?;

            return Err(std::io::Error::other(format!(
                "Snapshot '{}' differs. New snapshot written to {:?}",
                name, new_path
            ))
            .into());
        }

        Ok(())
    }
}

#[cfg(test)]
mod tests;