Skip to main content

journey/backend/
fixture.rs

1//! An in-memory [`RepoBackend`] for tests and demos.
2//!
3//! [`FixtureBackend`] holds a hand-built commit history with deterministic
4//! SHAs, timestamps, refs, file lists and diffs, so snapshot tests render
5//! identical pixels on every machine without touching a real repository.
6
7use std::cell::RefCell;
8use std::collections::{HashMap, HashSet};
9
10use super::{
11    ChangeStatus, CommitInfo, Diff, DiffLine, DiffLineKind, FileChange, RefKind, RefLabel,
12    RepoBackend, WorkingStatus,
13};
14
15/// A file changed by a commit, paired with the diff that produced it.
16pub struct FileEntry {
17    pub change: FileChange,
18    pub diff: Diff,
19}
20
21/// One path in the simulated working tree. Real git tracks separate
22/// working-vs-index and index-vs-HEAD diffs; the fixture keeps a single diff
23/// per file and a `staged` flag, which is enough to drive and snapshot the
24/// commit UI deterministically.
25struct WorkingEntry {
26    change: FileChange,
27    diff: Diff,
28    staged: bool,
29}
30
31pub struct FixtureBackend {
32    path: String,
33    commits: Vec<CommitInfo>,
34    files: HashMap<usize, Vec<FileEntry>>,
35    /// The simulated working tree, mutated by stage/unstage/commit.
36    working: RefCell<Vec<WorkingEntry>>,
37    /// Paths from the HEAD commit the user has pulled out of an in-progress
38    /// amend (so they show as unstaged instead of staged while amending).
39    amend_removed: RefCell<HashSet<String>>,
40    /// The last commit performed, recorded for test assertions: (message, amend).
41    last_commit: RefCell<Option<(String, bool)>>,
42    /// The simulated commit identity returned by [`RepoBackend::signature`].
43    signature: Option<(String, String)>,
44}
45
46impl FixtureBackend {
47    pub fn new(path: impl Into<String>) -> Self {
48        Self {
49            path: path.into(),
50            commits: Vec::new(),
51            files: HashMap::new(),
52            working: RefCell::new(Vec::new()),
53            amend_removed: RefCell::new(HashSet::new()),
54            last_commit: RefCell::new(None),
55            signature: Some(("Robert Lillack".to_string(), "rob@example.com".to_string())),
56        }
57    }
58
59    /// Override the simulated commit identity (or clear it with `None`).
60    pub fn with_signature(mut self, signature: Option<(String, String)>) -> Self {
61        self.signature = signature;
62        self
63    }
64
65    /// The files belonging to the `HEAD` commit (index 0, newest first) — the
66    /// changes that an amend would re-commit.
67    fn head_files(&self) -> &[FileEntry] {
68        self.files.get(&0).map(Vec::as_slice).unwrap_or(&[])
69    }
70
71    /// Append a commit and the files it touched.
72    pub fn add_commit(&mut self, info: CommitInfo, files: Vec<FileEntry>) -> &mut Self {
73        let idx = self.commits.len();
74        self.commits.push(info);
75        self.files.insert(idx, files);
76        self
77    }
78
79    /// Add a path to the simulated working tree (for commit-mode tests/demos).
80    pub fn add_working(
81        &mut self,
82        path: &str,
83        status: ChangeStatus,
84        staged: bool,
85        diff_lines: &[(DiffLineKind, &str)],
86    ) -> &mut Self {
87        self.working.borrow_mut().push(WorkingEntry {
88            change: FileChange {
89                path: path.to_string(),
90                old_path: None,
91                status,
92            },
93            diff: diff(diff_lines),
94            staged,
95        });
96        self
97    }
98
99    /// The most recent commit recorded via [`RepoBackend::commit`], as
100    /// `(message, amend)` — exposed for tests.
101    pub fn last_commit(&self) -> Option<(String, bool)> {
102        self.last_commit.borrow().clone()
103    }
104
105    /// A small, realistic history used by snapshot tests and as a demo when no
106    /// real repository is available. Five commits, two refs, varied statuses.
107    pub fn sample() -> Self {
108        let mut be = FixtureBackend::new("/home/rob/dev/journey");
109
110        be.add_commit(
111            commit(
112                "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678",
113                "Add commit DAG graph view",
114                "Robert Lillack",
115                "rob@example.com",
116                1_716_500_000,
117                120,
118                &["b2c3d4e5f60718293a4b5c6d7e8f90123456789a"],
119                &[("main", RefKind::Head)],
120            ),
121            vec![file_entry(
122                "src/widgets/graph.rs",
123                None,
124                ChangeStatus::Added,
125                &[
126                    (
127                        DiffLineKind::FileHeader,
128                        "diff --git a/src/widgets/graph.rs b/src/widgets/graph.rs",
129                    ),
130                    (DiffLineKind::FileHeader, "new file mode 100644"),
131                    (DiffLineKind::HunkHeader, "@@ -0,0 +1,3 @@"),
132                    (DiffLineKind::Addition, "+pub struct Graph {"),
133                    (DiffLineKind::Addition, "+    lanes: Vec<Lane>,"),
134                    (DiffLineKind::Addition, "+}"),
135                ],
136            )],
137        );
138
139        be.add_commit(
140            commit(
141                "b2c3d4e5f60718293a4b5c6d7e8f90123456789a",
142                "Build basic file list per commit",
143                "Robert Lillack",
144                "rob@example.com",
145                1_716_400_000,
146                120,
147                &["c3d4e5f60718293a4b5c6d7e8f90123456789ab2"],
148                &[("v0.2", RefKind::Tag)],
149            ),
150            vec![
151                file_entry(
152                    "src/backend.rs",
153                    None,
154                    ChangeStatus::Modified,
155                    &[
156                        (
157                            DiffLineKind::FileHeader,
158                            "diff --git a/src/backend.rs b/src/backend.rs",
159                        ),
160                        (
161                            DiffLineKind::HunkHeader,
162                            "@@ -10,6 +10,10 @@ impl Backend {",
163                        ),
164                        (
165                            DiffLineKind::Context,
166                            "     pub fn log(&self) -> Vec<Commit> {",
167                        ),
168                        (
169                            DiffLineKind::Addition,
170                            "+        // collect changed files too",
171                        ),
172                        (DiffLineKind::Addition, "+        self.changed_files();"),
173                        (DiffLineKind::Context, "         self.commits.clone()"),
174                        (DiffLineKind::Context, "     }"),
175                    ],
176                ),
177                file_entry(
178                    "src/main.rs",
179                    None,
180                    ChangeStatus::Modified,
181                    &[
182                        (
183                            DiffLineKind::FileHeader,
184                            "diff --git a/src/main.rs b/src/main.rs",
185                        ),
186                        (DiffLineKind::HunkHeader, "@@ -42,7 +42,7 @@"),
187                        (DiffLineKind::Deletion, "-    let files = vec![];"),
188                        (
189                            DiffLineKind::Addition,
190                            "+    let files = backend.changed_files(idx);",
191                        ),
192                    ],
193                ),
194            ],
195        );
196
197        be.add_commit(
198            commit(
199                "c3d4e5f60718293a4b5c6d7e8f90123456789ab2",
200                "Show path in title",
201                "Robert Lillack",
202                "rob@example.com",
203                1_716_300_000,
204                120,
205                &["d4e5f60718293a4b5c6d7e8f90123456789ab2c3"],
206                &[],
207            ),
208            vec![file_entry(
209                "src/main.rs",
210                None,
211                ChangeStatus::Modified,
212                &[
213                    (
214                        DiffLineKind::FileHeader,
215                        "diff --git a/src/main.rs b/src/main.rs",
216                    ),
217                    (DiffLineKind::HunkHeader, "@@ -20,1 +20,1 @@ fn title()"),
218                    (DiffLineKind::Deletion, "-        String::from(\"Journey\")"),
219                    (
220                        DiffLineKind::Addition,
221                        "+        format!(\"Journey: {}\", path)",
222                    ),
223                ],
224            )],
225        );
226
227        be.add_commit(
228            commit(
229                "d4e5f60718293a4b5c6d7e8f90123456789ab2c3",
230                "Rename boldFont() -> bold_font()",
231                "Robert Lillack",
232                "rob@example.com",
233                1_716_200_000,
234                120,
235                &["e5f60718293a4b5c6d7e8f90123456789ab2c3d4"],
236                &[("origin/main", RefKind::RemoteBranch)],
237            ),
238            vec![file_entry(
239                "src/style.rs",
240                None,
241                ChangeStatus::Modified,
242                &[
243                    (
244                        DiffLineKind::FileHeader,
245                        "diff --git a/src/style.rs b/src/style.rs",
246                    ),
247                    (DiffLineKind::HunkHeader, "@@ -80,4 +80,4 @@"),
248                    (DiffLineKind::Deletion, "-pub fn boldFont() -> Font {"),
249                    (DiffLineKind::Addition, "+pub fn bold_font() -> Font {"),
250                ],
251            )],
252        );
253
254        be.add_commit(
255            commit(
256                "e5f60718293a4b5c6d7e8f90123456789ab2c3d4",
257                "Initial import",
258                "Robert Lillack",
259                "rob@example.com",
260                1_716_100_000,
261                120,
262                &[],
263                &[],
264            ),
265            vec![
266                file_entry(
267                    "Cargo.toml",
268                    None,
269                    ChangeStatus::Added,
270                    &[
271                        (
272                            DiffLineKind::FileHeader,
273                            "diff --git a/Cargo.toml b/Cargo.toml",
274                        ),
275                        (DiffLineKind::FileHeader, "new file mode 100644"),
276                        (DiffLineKind::HunkHeader, "@@ -0,0 +1,2 @@"),
277                        (DiffLineKind::Addition, "+[package]"),
278                        (DiffLineKind::Addition, "+name = \"journey\""),
279                    ],
280                ),
281                file_entry(
282                    "src/main.rs",
283                    None,
284                    ChangeStatus::Added,
285                    &[
286                        (
287                            DiffLineKind::FileHeader,
288                            "diff --git a/src/main.rs b/src/main.rs",
289                        ),
290                        (DiffLineKind::FileHeader, "new file mode 100644"),
291                        (DiffLineKind::HunkHeader, "@@ -0,0 +1,1 @@"),
292                        (DiffLineKind::Addition, "+fn main() {}"),
293                    ],
294                ),
295            ],
296        );
297
298        // A working tree with a realistic mix of staged and unstaged changes,
299        // so commit mode has something to show.
300        be.add_working(
301            "src/ui.rs",
302            ChangeStatus::Modified,
303            false,
304            &[
305                (
306                    DiffLineKind::FileHeader,
307                    "diff --git a/src/ui.rs b/src/ui.rs",
308                ),
309                (
310                    DiffLineKind::HunkHeader,
311                    "@@ -40,6 +40,9 @@ impl GitClient {",
312                ),
313                (DiffLineKind::Context, "     fn sync(&mut self) {"),
314                (
315                    DiffLineKind::Addition,
316                    "+        // refresh the working-tree panes",
317                ),
318                (DiffLineKind::Addition, "+        self.rescan();"),
319                (DiffLineKind::Context, "         self.repaint();"),
320                (DiffLineKind::Context, "     }"),
321            ],
322        );
323        be.add_working(
324            "notes.md",
325            ChangeStatus::Untracked,
326            false,
327            &[
328                (DiffLineKind::FileHeader, "diff --git a/notes.md b/notes.md"),
329                (DiffLineKind::FileHeader, "new file mode 100644"),
330                (DiffLineKind::HunkHeader, "@@ -0,0 +1,2 @@"),
331                (DiffLineKind::Addition, "+# Notes"),
332                (DiffLineKind::Addition, "+- wire up commit mode"),
333            ],
334        );
335        be.add_working(
336            "src/widgets/commit_panel.rs",
337            ChangeStatus::Added,
338            true,
339            &[
340                (
341                    DiffLineKind::FileHeader,
342                    "diff --git a/src/widgets/commit_panel.rs b/src/widgets/commit_panel.rs",
343                ),
344                (DiffLineKind::FileHeader, "new file mode 100644"),
345                (DiffLineKind::HunkHeader, "@@ -0,0 +1,3 @@"),
346                (DiffLineKind::Addition, "+pub struct CommitPanel {"),
347                (DiffLineKind::Addition, "+    message: String,"),
348                (DiffLineKind::Addition, "+}"),
349            ],
350        );
351        be.add_working(
352            "Cargo.toml",
353            ChangeStatus::Modified,
354            true,
355            &[
356                (
357                    DiffLineKind::FileHeader,
358                    "diff --git a/Cargo.toml b/Cargo.toml",
359                ),
360                (
361                    DiffLineKind::HunkHeader,
362                    "@@ -8,3 +8,4 @@ edition = \"2024\"",
363                ),
364                (DiffLineKind::Context, " [dependencies]"),
365                (
366                    DiffLineKind::Addition,
367                    "+git2 = { version = \"0.18\", default-features = false }",
368                ),
369                (
370                    DiffLineKind::Context,
371                    " saudade = { path = \"../saudade\" }",
372                ),
373            ],
374        );
375
376        be
377    }
378}
379
380impl RepoBackend for FixtureBackend {
381    fn path(&self) -> &str {
382        &self.path
383    }
384
385    fn commits(&self) -> &[CommitInfo] {
386        &self.commits
387    }
388
389    fn changed_files(&self, index: usize) -> Vec<FileChange> {
390        self.files
391            .get(&index)
392            .map(|entries| entries.iter().map(|e| e.change.clone()).collect())
393            .unwrap_or_default()
394    }
395
396    fn commit_diff(&self, index: usize) -> Diff {
397        let mut lines = Vec::new();
398        if let Some(entries) = self.files.get(&index) {
399            for entry in entries {
400                lines.extend(entry.diff.lines.iter().cloned());
401            }
402        }
403        Diff { lines }
404    }
405
406    fn file_diff(&self, index: usize, path: &str) -> Diff {
407        self.files
408            .get(&index)
409            .and_then(|entries| entries.iter().find(|e| e.change.path == path))
410            .map(|e| e.diff.clone())
411            .unwrap_or_default()
412    }
413
414    fn working_status(&self, amend: bool) -> WorkingStatus {
415        let mut status = WorkingStatus::default();
416        for entry in self.working.borrow().iter() {
417            if entry.staged {
418                status.staged.push(entry.change.clone());
419            } else {
420                status.unstaged.push(entry.change.clone());
421            }
422        }
423        // When amending, the HEAD commit's files join the staged side (they'll
424        // be re-committed) unless the user has pulled them back out.
425        if amend {
426            let removed = self.amend_removed.borrow();
427            for fe in self.head_files() {
428                if removed.contains(&fe.change.path) {
429                    status.unstaged.push(fe.change.clone());
430                } else {
431                    status.staged.push(fe.change.clone());
432                }
433            }
434        }
435        status
436    }
437
438    fn working_diff(&self, path: &str, _staged: bool, amend: bool) -> Diff {
439        // The simulation keeps a single diff per path, so the staged/unstaged
440        // side doesn't change which diff we show.
441        if let Some(diff) = self
442            .working
443            .borrow()
444            .iter()
445            .find(|e| e.change.path == path)
446            .map(|e| e.diff.clone())
447        {
448            return diff;
449        }
450        if amend
451            && let Some(diff) = self
452                .head_files()
453                .iter()
454                .find(|fe| fe.change.path == path)
455                .map(|fe| fe.diff.clone())
456        {
457            return diff;
458        }
459        Diff::default()
460    }
461
462    fn stage(&self, path: &str) -> Result<(), String> {
463        let mut found = false;
464        for entry in self.working.borrow_mut().iter_mut() {
465            if entry.change.path == path {
466                entry.staged = true;
467                found = true;
468            }
469        }
470        // Re-staging a HEAD file that had been pulled out of an amend.
471        if !found {
472            self.amend_removed.borrow_mut().remove(path);
473        }
474        Ok(())
475    }
476
477    fn unstage(&self, path: &str, amend: bool) -> Result<(), String> {
478        let mut found = false;
479        for entry in self.working.borrow_mut().iter_mut() {
480            if entry.change.path == path {
481                entry.staged = false;
482                found = true;
483            }
484        }
485        // Dropping a HEAD file from the commit being amended.
486        if !found && amend {
487            self.amend_removed.borrow_mut().insert(path.to_string());
488        }
489        Ok(())
490    }
491
492    fn revert(&self, path: &str) -> Result<(), String> {
493        // Drop the unstaged (working-vs-index) entry for this path, the
494        // simulation's stand-in for restoring it from the index. Staged entries
495        // and untracked files are left alone, matching the live backend.
496        self.working.borrow_mut().retain(|e| {
497            !(e.change.path == path && !e.staged && e.change.status != ChangeStatus::Untracked)
498        });
499        Ok(())
500    }
501
502    fn delete_untracked(&self, path: &str) -> Result<(), String> {
503        // Removing the untracked file takes it out of the simulated working
504        // tree entirely.
505        self.working.borrow_mut().retain(|e| {
506            !(e.change.path == path && !e.staged && e.change.status == ChangeStatus::Untracked)
507        });
508        Ok(())
509    }
510
511    fn apply_to_index(&self, _patch: &str) -> Result<(), String> {
512        // The simulation keeps only a whole-file `staged` flag and no file
513        // contents, so it can't model staging a subset of lines. Accept the
514        // patch as a no-op so commit-mode UI tests can exercise the Stage/
515        // Unstage button without a spurious error dialog; partial-staging
516        // correctness is covered against the live backend instead.
517        Ok(())
518    }
519
520    fn commit(&self, message: &str, amend: bool) -> Result<(), String> {
521        if message.trim().is_empty() {
522            return Err("Please enter a commit message.".into());
523        }
524        // The staged changes are now part of HEAD; drop them from the
525        // working set so the panes clear after committing.
526        self.working.borrow_mut().retain(|e| !e.staged);
527        self.amend_removed.borrow_mut().clear();
528        *self.last_commit.borrow_mut() = Some((message.to_string(), amend));
529        Ok(())
530    }
531
532    fn head_message(&self) -> Option<String> {
533        self.commits.first().map(|c| c.message.clone())
534    }
535
536    fn signature(&self) -> Option<(String, String)> {
537        self.signature.clone()
538    }
539}
540
541/// Build a [`CommitInfo`] without the ceremony of naming every field.
542#[allow(clippy::too_many_arguments)]
543pub fn commit(
544    id: &str,
545    summary: &str,
546    author: &str,
547    email: &str,
548    time_seconds: i64,
549    time_offset_minutes: i32,
550    parents: &[&str],
551    refs: &[(&str, RefKind)],
552) -> CommitInfo {
553    CommitInfo {
554        id: id.to_string(),
555        short_id: id.chars().take(8).collect(),
556        summary: summary.to_string(),
557        message: format!("{summary}\n"),
558        author_name: author.to_string(),
559        author_email: email.to_string(),
560        committer_name: author.to_string(),
561        committer_email: email.to_string(),
562        time_seconds,
563        time_offset_minutes,
564        parents: parents.iter().map(|p| p.to_string()).collect(),
565        refs: refs
566            .iter()
567            .map(|(name, kind)| RefLabel {
568                name: name.to_string(),
569                kind: *kind,
570            })
571            .collect(),
572    }
573}
574
575fn file_entry(
576    path: &str,
577    old_path: Option<&str>,
578    status: ChangeStatus,
579    diff_lines: &[(DiffLineKind, &str)],
580) -> FileEntry {
581    FileEntry {
582        change: FileChange {
583            path: path.to_string(),
584            old_path: old_path.map(str::to_string),
585            status,
586        },
587        diff: diff(diff_lines),
588    }
589}
590
591/// Build a [`Diff`] from `(kind, text)` pairs.
592fn diff(lines: &[(DiffLineKind, &str)]) -> Diff {
593    Diff {
594        lines: lines
595            .iter()
596            .map(|(kind, text)| DiffLine::new(*kind, text.to_string()))
597            .collect(),
598    }
599}