1use 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
21const TTY_PROGRESS_INTERVAL: Duration = Duration::from_millis(950);
28
29#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum ReviewSyncOutcome {
37 Skipped,
39 UpToDate,
41 GitUnavailable,
43 Synced {
47 old_commit: Option<String>,
48 new_commit: String,
49 progress_emits: usize,
50 },
51}
52
53pub 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 let sync_root = crate::db::sync_root_for(project_root);
87
88 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
89 let freshness = check_branch_freshness(&branch_repo, project_root, branch_id);
90 run_review_sync_with_freshness(
91 db,
92 project_root,
93 &sync_root,
94 branch_id,
95 freshness,
96 progress_callback,
97 )
98}
99
100#[allow(clippy::too_many_arguments)]
107pub fn run_review_sync_with_freshness(
108 db: &Database,
109 project_root: &std::path::Path,
110 sync_root: &std::path::Path,
111 branch_id: &BranchId,
112 freshness: FreshnessCheck,
113 progress_callback: Option<&dyn Fn(usize, usize)>,
114) -> ReviewSyncOutcome {
115 let (old_commit, new_commit) = match freshness {
116 FreshnessCheck::UpToDate => return ReviewSyncOutcome::UpToDate,
117 FreshnessCheck::GitUnavailable => return ReviewSyncOutcome::GitUnavailable,
118 FreshnessCheck::Stale {
119 old_commit,
120 new_commit,
121 } => (old_commit, new_commit),
122 };
123
124 let config = AppConfig::load().unwrap_or_default();
125
126 let emits = std::sync::atomic::AtomicUsize::new(0);
127 let counted_cb = |processed: usize, total: usize| {
128 emits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
129 if let Some(cb) = progress_callback {
130 cb(processed, total);
131 }
132 };
133
134 crate::serve::incremental_sync_blocking(
135 project_root,
136 sync_root,
137 old_commit.as_deref(),
138 &branch_id.0,
139 db,
140 branch_id,
141 &config.scan,
142 &config.detection,
143 Some(&counted_cb),
144 );
145
146 ReviewSyncOutcome::Synced {
147 old_commit,
148 new_commit,
149 progress_emits: emits.load(std::sync::atomic::Ordering::Relaxed),
150 }
151}
152
153fn tty_progress_printer(head_short: String) -> impl Fn(usize, usize) {
160 let last_emit = Mutex::new(Instant::now() - TTY_PROGRESS_INTERVAL);
161 move |processed: usize, total: usize| {
162 let mut guard = match last_emit.lock() {
163 Ok(g) => g,
164 Err(p) => p.into_inner(),
165 };
166 if processed < total && guard.elapsed() < TTY_PROGRESS_INTERVAL {
167 return;
168 }
169 *guard = Instant::now();
170 drop(guard);
171 let mut stderr = std::io::stderr().lock();
172 let _ = write!(
173 stderr,
174 "\rSyncing project state to {head_short}... Files: {processed} / {total} "
175 );
176 let _ = stderr.flush();
177 }
178}
179
180fn piped_progress_printer(head_short: String) -> impl Fn(usize, usize) {
186 let last_emit = Mutex::new(Instant::now() - TTY_PROGRESS_INTERVAL);
187 move |processed: usize, total: usize| {
188 let mut guard = match last_emit.lock() {
189 Ok(g) => g,
190 Err(p) => p.into_inner(),
191 };
192 if processed < total && guard.elapsed() < TTY_PROGRESS_INTERVAL {
193 return;
194 }
195 *guard = Instant::now();
196 drop(guard);
197 eprintln!("Syncing project state to {head_short}: {processed} / {total} files");
198 }
199}
200
201pub fn run_review(project_path: Option<PathBuf>, no_sync: bool) -> Result<(), CliError> {
209 let explicit = project_path.as_deref();
211 let resolved = crate::db::resolve_project(explicit, "review")?;
212
213 if !resolved.db_path.exists() {
215 return Err(CliError::CommandFailed {
216 command: "review".to_owned(),
217 reason: "No database found. Run `seshat scan` first.".to_owned(),
218 });
219 }
220
221 let branch_id_str =
223 crate::db::get_current_branch(&resolved.project_root).unwrap_or_else(|| {
224 tracing::debug!(
225 path = %resolved.project_root.display(),
226 "Could not detect git branch, defaulting to 'main'"
227 );
228 "main".to_string()
229 });
230 let branch_id = BranchId::from(branch_id_str.as_str());
231
232 let db = Database::open(&resolved.db_path).map_err(|e| CliError::CommandFailed {
235 command: "review".to_owned(),
236 reason: format!("failed to open database: {e}"),
237 })?;
238
239 if no_sync {
241 prepare_review_sync(&db, &resolved.project_root, &branch_id, true, None);
242 } else {
243 let sync_root = resolved.sync_root().to_path_buf();
250 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
251 let freshness = check_branch_freshness(&branch_repo, &resolved.project_root, &branch_id);
253 match &freshness {
254 FreshnessCheck::UpToDate => {
255 tracing::debug!(branch = %branch_id.0, "review: DB is up to date with HEAD");
256 }
257 FreshnessCheck::GitUnavailable => {
258 tracing::debug!(
259 root = %sync_root.display(),
260 "review: git unavailable, skipping freshness check"
261 );
262 }
263 FreshnessCheck::Stale { new_commit, .. } => {
264 let head_short: String = new_commit.chars().take(7).collect();
265 let is_stdout_tty = std::io::stdout().is_terminal();
274 if is_stdout_tty {
275 print!("Syncing project state to {head_short}... ");
276 let _ = std::io::stdout().lock().flush();
277 } else {
278 println!("Syncing project state to {head_short}...");
279 }
280 if is_stdout_tty {
281 let printer = tty_progress_printer(head_short.clone());
282 run_review_sync_with_freshness(
283 &db,
284 &resolved.project_root,
285 &sync_root,
286 &branch_id,
287 freshness.clone(),
288 Some(&printer),
289 );
290 println!("\rSyncing project state to {head_short}... done. ");
294 let _ = std::io::stdout().lock().flush();
295 } else {
296 let printer = piped_progress_printer(head_short.clone());
297 run_review_sync_with_freshness(
298 &db,
299 &resolved.project_root,
300 &sync_root,
301 &branch_id,
302 freshness.clone(),
303 Some(&printer),
304 );
305 println!("Sync complete.");
306 let _ = std::io::stdout().lock().flush();
307 }
308 }
309 }
310 }
311
312 let conn = db.connection().clone();
313 crate::tui::run_review_tui_with_conn(&branch_id_str, &conn)?;
314
315 Ok(())
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use tempfile::tempdir;
322
323 #[test]
324 fn run_review_nonexistent_project_returns_error() {
325 let result = run_review(
326 Some(PathBuf::from("/tmp/seshat-nonexistent-review-test-xyz")),
327 false,
328 );
329 assert!(result.is_err());
330 }
331
332 #[test]
333 fn run_review_with_some_path_sets_deref() {
334 let tmp = tempdir().unwrap();
335 let db_path = tmp.path().join("seshat.db");
336
337 std::fs::write(&db_path, "fake db").unwrap();
338
339 let result = run_review(Some(tmp.path().to_path_buf()), false);
340 assert!(result.is_err());
341 }
342
343 #[test]
344 fn run_review_file_instead_of_directory_error() {
345 let tmp = tempdir().unwrap();
346 let file_path = tmp.path().join("just_a_file");
347 std::fs::write(&file_path, "hello").unwrap();
348 let result = run_review(Some(file_path), false);
349 assert!(result.is_err());
350 }
351
352 #[test]
355 fn prepare_review_sync_returns_skipped_when_no_sync_passed() {
356 let dir = tempdir().expect("tempdir");
357 let db = Database::open(":memory:").expect("open db");
358 let branch = BranchId::from("main");
359
360 let outcome = prepare_review_sync(&db, dir.path(), &branch, true, None);
361 assert_eq!(outcome, ReviewSyncOutcome::Skipped);
362 }
363
364 #[test]
365 fn prepare_review_sync_returns_git_unavailable_for_non_git_directory() {
366 let dir = tempdir().expect("tempdir");
367 let db = Database::open(":memory:").expect("open db");
368 let branch = BranchId::from("main");
369
370 let outcome = prepare_review_sync(&db, dir.path(), &branch, false, None);
371 assert_eq!(outcome, ReviewSyncOutcome::GitUnavailable);
372 }
373}