Skip to main content

seshat_cli/
review.rs

1//! Implementation of the `seshat review` command.
2//!
3//! Resolves the project DB, reads the active branch's `last_scanned_commit`,
4//! and — unless `--no-sync` is passed — performs a blocking incremental sync
5//! to the current `git rev-parse HEAD` before opening the TUI. The TUI then
6//! reads conventions that reflect the on-disk state, not a potentially stale
7//! snapshot from a previous scan.
8
9use std::io::{IsTerminal, Write};
10use std::path::PathBuf;
11use std::sync::Mutex;
12use std::time::{Duration, Instant};
13
14use seshat_core::BranchId;
15use seshat_scanner::{FreshnessCheck, check_branch_freshness};
16use seshat_storage::{Database, SqliteBranchRepository};
17
18use crate::config::AppConfig;
19use crate::error::CliError;
20
21/// Minimum delay between progress callback emits when stderr is a TTY.
22///
23/// Per US-011 AC: the progress UI updates "Files: X / Y on the same line at
24/// 1Hz" — so the throttle MUST allow 1 emit per second. Using exactly 1 second
25/// flickers under load when calls fall on either side of the boundary; 950 ms
26/// gives a stable ~1 Hz cadence without crossing into the "more than 1 Hz" zone.
27const TTY_PROGRESS_INTERVAL: Duration = Duration::from_millis(950);
28
29/// Outcome returned by [`prepare_review_sync`] so tests can drive the freshness
30/// gate without launching the (interactive) TUI.
31///
32/// Mirrors the variants of [`FreshnessCheck`] one-to-one for the gate cases,
33/// plus a `Synced` variant that carries the file counts the AC requires the
34/// progress callback to surface. `Skipped` covers `--no-sync`.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum ReviewSyncOutcome {
37    /// `--no-sync` was passed; sync gate was not consulted.
38    Skipped,
39    /// Freshness gate said up-to-date; no sync was run.
40    UpToDate,
41    /// Git was unavailable for the project root; sync was skipped silently.
42    GitUnavailable,
43    /// A blocking sync ran. `progress_emits` is the number of times the user-
44    /// facing progress callback fired (independent of internal upsert events,
45    /// which the throttle may have collapsed).
46    Synced {
47        old_commit: Option<String>,
48        new_commit: String,
49        progress_emits: usize,
50    },
51}
52
53/// Run the freshness gate and (when stale) the blocking incremental sync.
54///
55/// Extracted from [`run_review`] so integration tests can lock the gate at the
56/// same layer the CLI uses without spawning the TUI. The returned
57/// [`ReviewSyncOutcome`] is precisely what determines whether `run_review` calls
58/// `incremental_sync_blocking` before opening the TUI.
59///
60/// `progress_callback` lets tests inject a counting callback. In production,
61/// `run_review` passes a stderr-printing throttled callback (see
62/// `tty_progress_printer` / `piped_progress_printer`).
63pub fn prepare_review_sync(
64    db: &Database,
65    project_root: &std::path::Path,
66    branch_id: &BranchId,
67    no_sync: bool,
68    progress_callback: Option<&dyn Fn(usize, usize)>,
69) -> ReviewSyncOutcome {
70    if no_sync {
71        tracing::debug!(
72            branch = %branch_id.0,
73            "review: --no-sync passed, skipping freshness check"
74        );
75        return ReviewSyncOutcome::Skipped;
76    }
77
78    // The freshness check + sync need the git root (gix opens the repo and
79    // resolves refs from there). Use the shared sync_root_for helper so this
80    // matches the resolver's `sync_root()` semantics for non-git fallback.
81    let sync_root = crate::db::sync_root_for(project_root);
82
83    let branch_repo = SqliteBranchRepository::new(db.connection().clone());
84    let freshness = check_branch_freshness(&branch_repo, &sync_root, branch_id);
85    run_review_sync_with_freshness(db, &sync_root, branch_id, freshness, progress_callback)
86}
87
88/// Run the blocking incremental sync given an already-computed
89/// [`FreshnessCheck`]. Lets `run_review` consult the gate ONCE and then
90/// drive both its banner output and the actual sync from the same
91/// reading — pre-fix it ran the gate, then [`prepare_review_sync`] ran
92/// it AGAIN, opening a TOCTOU window where HEAD could move between the
93/// two reads (P23).
94pub fn run_review_sync_with_freshness(
95    db: &Database,
96    sync_root: &std::path::Path,
97    branch_id: &BranchId,
98    freshness: FreshnessCheck,
99    progress_callback: Option<&dyn Fn(usize, usize)>,
100) -> ReviewSyncOutcome {
101    let (old_commit, new_commit) = match freshness {
102        FreshnessCheck::UpToDate => return ReviewSyncOutcome::UpToDate,
103        FreshnessCheck::GitUnavailable => return ReviewSyncOutcome::GitUnavailable,
104        FreshnessCheck::Stale {
105            old_commit,
106            new_commit,
107        } => (old_commit, new_commit),
108    };
109
110    let config = AppConfig::load().unwrap_or_default();
111
112    let emits = std::sync::atomic::AtomicUsize::new(0);
113    let counted_cb = |processed: usize, total: usize| {
114        emits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
115        if let Some(cb) = progress_callback {
116            cb(processed, total);
117        }
118    };
119
120    crate::serve::incremental_sync_blocking(
121        sync_root,
122        old_commit.as_deref(),
123        &branch_id.0,
124        db,
125        branch_id,
126        &config.scan,
127        &config.detection,
128        Some(&counted_cb),
129    );
130
131    ReviewSyncOutcome::Synced {
132        old_commit,
133        new_commit,
134        progress_emits: emits.load(std::sync::atomic::Ordering::Relaxed),
135    }
136}
137
138/// Build a TTY-aware throttled progress printer.
139///
140/// Emits `Syncing project state to <head[..7]>... Files: X / Y` to stderr,
141/// rewriting the same line at most once per [`TTY_PROGRESS_INTERVAL`] (≈1 Hz).
142/// The final `(total, total)` tick is always emitted (the throttle gates only
143/// the intermediate updates) so the user sees the completion line.
144fn tty_progress_printer(head_short: String) -> impl Fn(usize, usize) {
145    let last_emit = Mutex::new(Instant::now() - TTY_PROGRESS_INTERVAL);
146    move |processed: usize, total: usize| {
147        let mut guard = match last_emit.lock() {
148            Ok(g) => g,
149            Err(p) => p.into_inner(),
150        };
151        if processed < total && guard.elapsed() < TTY_PROGRESS_INTERVAL {
152            return;
153        }
154        *guard = Instant::now();
155        drop(guard);
156        let mut stderr = std::io::stderr().lock();
157        let _ = write!(
158            stderr,
159            "\rSyncing project state to {head_short}... Files: {processed} / {total}    "
160        );
161        let _ = stderr.flush();
162    }
163}
164
165/// Build a non-TTY (piped) progress printer.
166///
167/// Emits one line per throttled update: `Syncing files: X / Y`. Used when
168/// stderr is not a terminal (CI logs, tee, redirected output) so that lines
169/// are preserved instead of being overwritten with carriage returns.
170fn piped_progress_printer(head_short: String) -> impl Fn(usize, usize) {
171    let last_emit = Mutex::new(Instant::now() - TTY_PROGRESS_INTERVAL);
172    move |processed: usize, total: usize| {
173        let mut guard = match last_emit.lock() {
174            Ok(g) => g,
175            Err(p) => p.into_inner(),
176        };
177        if processed < total && guard.elapsed() < TTY_PROGRESS_INTERVAL {
178            return;
179        }
180        *guard = Instant::now();
181        drop(guard);
182        eprintln!("Syncing project state to {head_short}: {processed} / {total} files");
183    }
184}
185
186/// Run the `seshat review` command end-to-end.
187///
188/// 1. Resolves the project DB and active branch.
189/// 2. Unless `no_sync` is set, runs the freshness gate; on `Stale`, runs an
190///    incremental blocking sync to HEAD with a stderr progress UI (TTY: same-
191///    line `\r` rewrite at 1 Hz; piped: one line per throttled update).
192/// 3. Launches the TUI with the (now-fresh) connection.
193pub fn run_review(project_path: Option<PathBuf>, no_sync: bool) -> Result<(), CliError> {
194    // Resolve the project — shared resolver also used by serve/status.
195    let explicit = project_path.as_deref();
196    let resolved = crate::db::resolve_project(explicit, "review")?;
197
198    // Check that the database actually exists.
199    if !resolved.db_path.exists() {
200        return Err(CliError::CommandFailed {
201            command: "review".to_owned(),
202            reason: "No database found. Run `seshat scan` first.".to_owned(),
203        });
204    }
205
206    // Determine branch once and pass it through to all downstream calls.
207    let branch_id_str =
208        crate::db::get_current_branch(&resolved.project_root).unwrap_or_else(|| {
209            tracing::debug!(
210               path = %resolved.project_root.display(),
211                "Could not detect git branch, defaulting to 'main'"
212            );
213            "main".to_string()
214        });
215    let branch_id = BranchId::from(branch_id_str.as_str());
216
217    // Open via Database so the freshness check, the blocking sync, and the TUI
218    // can all share one Arc<Mutex<Connection>> handle.
219    let db = Database::open(&resolved.db_path).map_err(|e| CliError::CommandFailed {
220        command: "review".to_owned(),
221        reason: format!("failed to open database: {e}"),
222    })?;
223
224    // -- Freshness gate + blocking sync ---------------------------------
225    if no_sync {
226        prepare_review_sync(&db, &resolved.project_root, &branch_id, true, None);
227    } else {
228        // P23: consult the freshness gate ONCE and pass the result down.
229        // Pre-fix this ran the gate here, then prepare_review_sync ran
230        // it AGAIN — a TOCTOU window where HEAD could move between the
231        // two reads (banner announcing commit X, sync against commit Y).
232        // The resolver already walked to the git common-dir, so we just
233        // borrow it (or fall back to project_root for non-git).
234        let sync_root = resolved.sync_root().to_path_buf();
235        let branch_repo = SqliteBranchRepository::new(db.connection().clone());
236        let freshness = check_branch_freshness(&branch_repo, &sync_root, &branch_id);
237        match &freshness {
238            FreshnessCheck::UpToDate => {
239                tracing::debug!(branch = %branch_id.0, "review: DB is up to date with HEAD");
240            }
241            FreshnessCheck::GitUnavailable => {
242                tracing::debug!(
243                    root = %sync_root.display(),
244                    "review: git unavailable, skipping freshness check"
245                );
246            }
247            FreshnessCheck::Stale { new_commit, .. } => {
248                let head_short: String = new_commit.chars().take(7).collect();
249                // P22: PRD US-011 specifies stdout for the user-facing
250                // banner ("Syncing project state to ..."). The progress
251                // printer keeps writing to stderr — that's the standard
252                // place for transient progress info that should not
253                // pollute redirected stdout, and the printers handle TTY
254                // detection on stderr internally. The TTY check here
255                // gates the banner format only, against stdout, since
256                // that's what the spec wires to it.
257                let is_stdout_tty = std::io::stdout().is_terminal();
258                if is_stdout_tty {
259                    print!("Syncing project state to {head_short}... ");
260                    let _ = std::io::stdout().lock().flush();
261                } else {
262                    println!("Syncing project state to {head_short}...");
263                }
264                if is_stdout_tty {
265                    let printer = tty_progress_printer(head_short.clone());
266                    run_review_sync_with_freshness(
267                        &db,
268                        &sync_root,
269                        &branch_id,
270                        freshness.clone(),
271                        Some(&printer),
272                    );
273                    // Newline after the in-place progress line, plus a "done"
274                    // marker so the user knows the sync finished before the
275                    // TUI takes over the screen.
276                    println!("\rSyncing project state to {head_short}... done.            ");
277                    let _ = std::io::stdout().lock().flush();
278                } else {
279                    let printer = piped_progress_printer(head_short.clone());
280                    run_review_sync_with_freshness(
281                        &db,
282                        &sync_root,
283                        &branch_id,
284                        freshness.clone(),
285                        Some(&printer),
286                    );
287                    println!("Sync complete.");
288                    let _ = std::io::stdout().lock().flush();
289                }
290            }
291        }
292    }
293
294    let conn = db.connection().clone();
295    crate::tui::run_review_tui_with_conn(&branch_id_str, &conn)?;
296
297    Ok(())
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use tempfile::tempdir;
304
305    #[test]
306    fn run_review_nonexistent_project_returns_error() {
307        let result = run_review(
308            Some(PathBuf::from("/tmp/seshat-nonexistent-review-test-xyz")),
309            false,
310        );
311        assert!(result.is_err());
312    }
313
314    #[test]
315    fn run_review_with_some_path_sets_deref() {
316        let tmp = tempdir().unwrap();
317        let db_path = tmp.path().join("seshat.db");
318
319        std::fs::write(&db_path, "fake db").unwrap();
320
321        let result = run_review(Some(tmp.path().to_path_buf()), false);
322        assert!(result.is_err());
323    }
324
325    #[test]
326    fn run_review_file_instead_of_directory_error() {
327        let tmp = tempdir().unwrap();
328        let file_path = tmp.path().join("just_a_file");
329        std::fs::write(&file_path, "hello").unwrap();
330        let result = run_review(Some(file_path), false);
331        assert!(result.is_err());
332    }
333
334    // ── prepare_review_sync ─────────────────────────────────────────────
335
336    #[test]
337    fn prepare_review_sync_returns_skipped_when_no_sync_passed() {
338        let dir = tempdir().expect("tempdir");
339        let db = Database::open(":memory:").expect("open db");
340        let branch = BranchId::from("main");
341
342        let outcome = prepare_review_sync(&db, dir.path(), &branch, true, None);
343        assert_eq!(outcome, ReviewSyncOutcome::Skipped);
344    }
345
346    #[test]
347    fn prepare_review_sync_returns_git_unavailable_for_non_git_directory() {
348        let dir = tempdir().expect("tempdir");
349        let db = Database::open(":memory:").expect("open db");
350        let branch = BranchId::from("main");
351
352        let outcome = prepare_review_sync(&db, dir.path(), &branch, false, None);
353        assert_eq!(outcome, ReviewSyncOutcome::GitUnavailable);
354    }
355}