1use std::collections::HashMap;
33use std::io::{self, Write as _};
34use std::path::{Path, PathBuf};
35
36use anyhow::{anyhow, bail, Context, Result};
37use clap::{Args, Subcommand, ValueEnum};
38use serde::{Deserialize, Serialize};
39
40use crate::dates::DateRange;
41use crate::db::Db;
42use crate::durable_fs::{entry_exists, StagedDirectory};
43use crate::models::{
44 EditOp, FileCrossRef, FileEdit, FileEditSummary, FileQuery, FileVersion, Provider,
45};
46use crate::render::{render, OutputFormat, Row};
47
48pub(crate) type FileMutationPayload = (String, Option<String>, Vec<EditOp>);
49
50#[derive(Default)]
51pub(crate) struct PendingFileMutations {
52 by_call_id: HashMap<String, PendingFileMutation>,
55 next_seq: i64,
56}
57
58struct PendingFileMutation {
59 ts: Option<chrono::DateTime<chrono::Utc>>,
60 tool: String,
61 payloads: Vec<FileMutationPayload>,
62}
63
64impl PendingFileMutations {
65 pub(crate) fn stage(
66 &mut self,
67 call_id: Option<&str>,
68 ts: Option<chrono::DateTime<chrono::Utc>>,
69 tool: &str,
70 payload: Option<FileMutationPayload>,
71 ) {
72 let Some(payload) = payload else {
73 return;
74 };
75 self.stage_many(call_id, ts, tool, vec![payload]);
76 }
77
78 pub(crate) fn stage_many(
79 &mut self,
80 call_id: Option<&str>,
81 ts: Option<chrono::DateTime<chrono::Utc>>,
82 tool: &str,
83 payloads: Vec<FileMutationPayload>,
84 ) {
85 let Some(call_id) = call_id.filter(|id| !id.is_empty()) else {
86 return;
87 };
88 if payloads.is_empty() {
89 return;
90 }
91 self.by_call_id.insert(
92 call_id.to_string(),
93 PendingFileMutation {
94 ts,
95 tool: tool.to_string(),
96 payloads,
97 },
98 );
99 }
100
101 pub(crate) fn finish(
102 &mut self,
103 call_id: Option<&str>,
104 succeeded: bool,
105 out: &mut Vec<FileEdit>,
106 ) {
107 let Some(call_id) = call_id else {
108 return;
109 };
110 let Some(pending) = self.by_call_id.remove(call_id) else {
111 return;
112 };
113 if !succeeded {
114 return;
115 }
116 for (file_path, new_content, edits) in pending.payloads {
117 out.push(FileEdit {
118 seq: self.next_seq,
119 ts: pending.ts,
120 tool: pending.tool.clone(),
121 file_name: crate::util::file_basename(&file_path),
122 file_path,
123 new_content,
124 edits,
125 });
126 self.next_seq += 1;
127 }
128 }
129}
130
131pub fn reconstruct(edits: &[FileEdit], version: usize) -> Option<String> {
138 if version == 0 || version > edits.len() {
139 return None;
140 }
141 let target = version - 1;
142 let base = (0..=target)
144 .rev()
145 .find(|&i| edits[i].new_content.is_some())?;
146 let mut content = edits[base].new_content.clone();
147 for edit in &edits[base + 1..=target] {
148 advance_reconstruction(&mut content, edit);
149 }
150 content
151}
152
153fn apply_edits(content: &mut String, edits: &[EditOp]) -> bool {
155 for op in edits {
156 if op.old.is_empty() {
157 return false;
158 }
159 if op.replace_all {
160 if !content.contains(op.old.as_str()) {
161 return false;
162 }
163 *content = content.replace(op.old.as_str(), &op.new);
164 } else if let Some(pos) = content.find(op.old.as_str()) {
165 content.replace_range(pos..pos + op.old.len(), &op.new);
166 } else {
167 return false;
168 }
169 }
170 true
171}
172
173fn advance_reconstruction(content: &mut Option<String>, edit: &FileEdit) {
176 if let Some(full) = &edit.new_content {
177 *content = Some(full.clone());
178 } else if edit.edits.is_empty()
179 || !content
180 .as_mut()
181 .is_some_and(|current| apply_edits(current, &edit.edits))
182 {
183 *content = None;
184 }
185}
186
187fn ensure_safe_restore_target(path: &Path) -> Result<()> {
193 use std::path::Component;
194 if path.components().any(|c| c == Component::ParentDir) {
195 bail!(
196 "refusing to restore to '{}': the session-recorded path contains '..'; \
197 pass --output-dir to choose a safe destination",
198 path.display()
199 );
200 }
201 Ok(())
202}
203
204fn safe_output_name(original: &Path, file_arg: &str) -> PathBuf {
210 original
211 .file_name()
212 .or_else(|| Path::new(file_arg).file_name())
213 .map(PathBuf::from)
214 .unwrap_or_else(|| PathBuf::from("recovered"))
215}
216
217fn restore_target<F: Fn(&Path) -> bool>(original: &Path, exists: F) -> PathBuf {
221 let dir = original.parent().unwrap_or_else(|| Path::new("."));
222 let stem = original
223 .file_stem()
224 .and_then(|s| s.to_str())
225 .unwrap_or("recovered");
226 let ext = original.extension().and_then(|s| s.to_str());
227 let candidate = |n: usize| -> PathBuf {
228 let marker = if n == 1 {
229 "recovered".to_string()
230 } else {
231 format!("recovered_{n}")
232 };
233 let name = match ext {
234 Some(ext) => format!("{stem}.{marker}.{ext}"),
235 None => format!("{stem}.{marker}"),
236 };
237 dir.join(name)
238 };
239 let mut n = 1;
240 loop {
241 let path = candidate(n);
242 if !exists(&path) {
243 return path;
244 }
245 n += 1;
246 }
247}
248
249struct PendingRestore {
250 path: PathBuf,
251 file: Option<std::fs::File>,
252}
253
254impl PendingRestore {
255 fn persist(mut self, content: &[u8]) -> Result<PathBuf> {
256 let file = self
257 .file
258 .as_mut()
259 .ok_or_else(|| anyhow!("recovery file was already finalized"))?;
260 file.write_all(content)
261 .with_context(|| format!("failed to write recovery file '{}'", self.path.display()))?;
262 file.sync_all()
263 .with_context(|| format!("failed to sync recovery file '{}'", self.path.display()))?;
264 self.file.take();
265 Ok(self.path.clone())
266 }
267}
268
269impl Drop for PendingRestore {
270 fn drop(&mut self) {
271 if self.file.take().is_some() {
272 let _ = std::fs::remove_file(&self.path);
273 }
274 }
275}
276
277fn create_recovery_file(base: &Path) -> Result<PendingRestore> {
278 if let Some(parent) = base.parent() {
279 std::fs::create_dir_all(parent).with_context(|| {
280 format!("failed to create recovery directory '{}'", parent.display())
281 })?;
282 }
283 loop {
284 let path = restore_target(base, Path::exists);
285 match std::fs::OpenOptions::new()
286 .write(true)
287 .create_new(true)
288 .open(&path)
289 {
290 Ok(file) => {
291 return Ok(PendingRestore {
292 path,
293 file: Some(file),
294 });
295 }
296 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
297 Err(error) => {
298 return Err(error).with_context(|| {
299 format!("failed to create recovery file '{}'", path.display())
300 });
301 }
302 }
303 }
304}
305
306pub fn restore_reconstructed(
314 reconstructed: &ReconstructedFile,
315 output_dir: Option<&Path>,
316) -> Result<PathBuf> {
317 let original = Path::new(&reconstructed.file_path);
318 let base = match output_dir {
319 Some(dir) => dir.join(safe_output_name(original, &reconstructed.file_path)),
320 None => {
321 if original.as_os_str().is_empty() {
322 bail!("cannot restore beside an empty session-recorded path; provide output_dir");
323 }
324 ensure_safe_restore_target(original)?;
325 original.to_path_buf()
326 }
327 };
328 create_recovery_file(&base)?.persist(reconstructed.content.as_bytes())
329}
330
331fn group_by_session(
334 rows: Vec<(String, Provider, FileEdit)>,
335) -> Vec<(String, Provider, Vec<FileEdit>)> {
336 let mut groups: Vec<(String, Provider, Vec<FileEdit>)> = Vec::new();
337 for (session_id, provider, edit) in rows {
338 match groups.last_mut() {
339 Some((sid, _, list)) if *sid == session_id => list.push(edit),
340 _ => groups.push((session_id, provider, vec![edit])),
341 }
342 }
343 groups
344}
345
346fn count_lines(content: &str) -> i64 {
347 content.lines().count() as i64
348}
349
350fn version_line_counts(edits: &[FileEdit]) -> Vec<i64> {
356 let mut content: Option<String> = None;
357 edits
358 .iter()
359 .map(|edit| {
360 advance_reconstruction(&mut content, edit);
361 content.as_deref().map(count_lines).unwrap_or(0)
362 })
363 .collect()
364}
365
366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
367pub struct ReconstructedFile {
368 pub session_id: String,
369 pub provider: Provider,
370 pub version: usize,
371 pub file_path: String,
372 pub content: String,
373}
374
375#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
383pub struct PublishedFileVersion {
384 pub source_session_id: String,
385 pub version: usize,
386 pub path: PathBuf,
387 pub sha256: String,
388}
389
390#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
392pub struct RecoveryPublicationReceipt {
393 pub destination: PathBuf,
394 pub files: Vec<PublishedFileVersion>,
395}
396
397fn versioned_output_name(reconstructed: &ReconstructedFile) -> PathBuf {
398 let original = Path::new(&reconstructed.file_path);
399 let base = safe_output_name(original, &reconstructed.file_path);
400 let stem = base
401 .file_stem()
402 .and_then(|value| value.to_str())
403 .unwrap_or("recovered");
404 match base.extension().and_then(|value| value.to_str()) {
405 Some(extension) => {
406 PathBuf::from(format!("{stem}_v{}.{}", reconstructed.version, extension))
407 }
408 None => PathBuf::from(format!("{stem}_v{}", reconstructed.version)),
409 }
410}
411
412fn recovery_publication_parent(destination: &Path) -> Result<&Path> {
413 if !destination.is_absolute() {
414 bail!(
415 "recovery publication destination must be absolute: {}",
416 destination.display()
417 );
418 }
419 if entry_exists(destination).with_context(|| {
420 format!(
421 "failed to inspect recovery publication destination {}",
422 destination.display()
423 )
424 })? {
425 bail!(
426 "recovery publication destination already exists: {}",
427 destination.display()
428 );
429 }
430 let parent = destination.parent().ok_or_else(|| {
431 anyhow!(
432 "recovery publication destination has no parent: {}",
433 destination.display()
434 )
435 })?;
436 if !parent.is_dir() {
437 bail!(
438 "recovery publication parent is not a directory: {}",
439 parent.display()
440 );
441 }
442 Ok(parent)
443}
444
445pub fn publish_reconstructed_versions<I>(
452 versions: I,
453 destination: &Path,
454) -> Result<RecoveryPublicationReceipt>
455where
456 I: IntoIterator<Item = ReconstructedFile>,
457{
458 let parent = recovery_publication_parent(destination)?;
459 let mut versions = versions.into_iter();
460 let first = versions
461 .next()
462 .ok_or_else(|| anyhow!("cannot publish an empty reconstructed-version directory"))?;
463
464 let staging = StagedDirectory::begin(parent, "recovery")?;
465 let mut files = Vec::new();
466 for reconstructed in std::iter::once(first).chain(versions) {
467 let name = versioned_output_name(&reconstructed);
468 let content = reconstructed.content.as_bytes();
469 staging.write(&name, content)?;
470 files.push(PublishedFileVersion {
471 source_session_id: reconstructed.session_id,
472 version: reconstructed.version,
473 path: destination.join(name),
474 sha256: crate::hashing::sha256(content),
475 });
476 }
477 staging.publish(destination)?;
478 Ok(RecoveryPublicationReceipt {
479 destination: destination.to_path_buf(),
480 files,
481 })
482}
483
484pub fn reconstruct_query(
486 db: &Db,
487 file: &str,
488 query: &FileQuery,
489 version: Option<usize>,
490) -> Result<ReconstructedFile> {
491 let (session_id, provider, edits) = selected_edit_group(db, file, query)?;
492 let selected = version.unwrap_or(edits.len());
493 if selected == 0 || selected > edits.len() {
494 bail!(
495 "version {selected} is out of range for '{file}'; expected 1..={}",
496 edits.len()
497 );
498 }
499 let content = reconstruct(&edits, selected).ok_or_else(|| {
500 anyhow!(
501 "cannot reconstruct version {selected} of '{file}': no complete replay path exists; a full-content base may be missing or an intervening edit may be path-only"
502 )
503 })?;
504 Ok(ReconstructedFile {
505 session_id,
506 provider,
507 version: selected,
508 file_path: edits[selected - 1].file_path.clone(),
509 content,
510 })
511}
512
513fn selected_edit_group(
514 db: &Db,
515 file: &str,
516 query: &FileQuery,
517) -> Result<(String, Provider, Vec<FileEdit>)> {
518 let mut groups = group_by_session(db.file_edits_for_query(file, query)?);
519 if groups.is_empty() {
520 bail!("no file edits found for '{file}'");
521 }
522 if groups.len() > 1 {
523 bail!(
524 "file '{file}' matched {} sessions; set an exact session_id before reconstruction",
525 groups.len()
526 );
527 }
528 groups
529 .pop()
530 .ok_or_else(|| anyhow!("reconstruction selection unexpectedly became empty"))
531}
532
533#[derive(Debug)]
540pub struct ReconstructedFileVersions {
541 session_id: String,
542 provider: Provider,
543 edits: std::vec::IntoIter<FileEdit>,
544 content: Option<String>,
545 version: usize,
546}
547
548impl Iterator for ReconstructedFileVersions {
549 type Item = ReconstructedFile;
550
551 fn next(&mut self) -> Option<Self::Item> {
552 for edit in self.edits.by_ref() {
553 self.version += 1;
554 advance_reconstruction(&mut self.content, &edit);
555 if let Some(content) = &self.content {
556 return Some(ReconstructedFile {
557 session_id: self.session_id.clone(),
558 provider: self.provider,
559 version: self.version,
560 file_path: edit.file_path,
561 content: content.clone(),
562 });
563 }
564 }
565 None
566 }
567
568 fn size_hint(&self) -> (usize, Option<usize>) {
569 (0, Some(self.edits.len()))
570 }
571}
572
573impl std::iter::FusedIterator for ReconstructedFileVersions {}
574
575pub fn reconstruct_versions_query(
577 db: &Db,
578 file: &str,
579 query: &FileQuery,
580) -> Result<ReconstructedFileVersions> {
581 let (session_id, provider, edits) = selected_edit_group(db, file, query)?;
582 if !edits.iter().any(|edit| edit.new_content.is_some()) {
583 bail!(
584 "cannot reconstruct any version of '{file}': no full-content base exists in the selected edit history"
585 );
586 }
587 Ok(ReconstructedFileVersions {
588 session_id,
589 provider,
590 edits: edits.into_iter(),
591 content: None,
592 version: 0,
593 })
594}
595
596pub fn history(db: &Db, file: &str, query: &FileQuery) -> Result<Vec<FileVersion>> {
599 let groups = group_by_session(db.file_edits_for_query(file, query)?);
600 let mut versions = Vec::new();
601 for (session_id, provider, edits) in &groups {
602 let line_counts = version_line_counts(edits);
603 for (index, edit) in edits.iter().enumerate() {
604 versions.push(FileVersion {
605 session_id: session_id.clone(),
606 provider: *provider,
607 version: (index + 1) as i64,
608 tool: edit.tool.clone(),
609 ts: edit.ts,
610 lines: line_counts[index],
611 file_path: edit.file_path.clone(),
612 });
613 }
614 }
615 if versions.is_empty() {
616 bail!("no file edits found for '{file}'");
617 }
618 let page = versions
619 .into_iter()
620 .skip(query.offset)
621 .take(if query.limit == 0 {
622 usize::MAX
623 } else {
624 query.limit
625 })
626 .collect();
627 Ok(page)
628}
629
630impl Row for FileEditSummary {
631 fn headers() -> &'static [&'static str] {
632 &["file", "edits", "sessions", "last_edited"]
633 }
634 fn cells(&self) -> Vec<String> {
635 vec![
636 self.file_path.clone(),
637 self.edits.to_string(),
638 self.sessions.to_string(),
639 self.last_edited
640 .map(|ts| ts.to_rfc3339())
641 .unwrap_or_default(),
642 ]
643 }
644}
645
646impl Row for FileVersion {
647 fn headers() -> &'static [&'static str] {
648 &["session", "version", "tool", "ts", "lines", "file"]
649 }
650 fn cells(&self) -> Vec<String> {
651 vec![
652 self.session_id.clone(),
653 self.version.to_string(),
654 self.tool.clone(),
655 self.ts.map(|ts| ts.to_rfc3339()).unwrap_or_default(),
656 self.lines.to_string(),
657 self.file_path.clone(),
658 ]
659 }
660}
661
662impl Row for FileCrossRef {
663 fn headers() -> &'static [&'static str] {
664 &["file", "session", "provider", "edits"]
665 }
666 fn cells(&self) -> Vec<String> {
667 vec![
668 self.file_path.clone(),
669 self.session_id.clone(),
670 self.provider.as_str().to_string(),
671 self.edits.to_string(),
672 ]
673 }
674}
675
676#[derive(Debug, Subcommand)]
677pub enum FilesCmd {
678 Search(FilesSearchArgs),
680 History(FilesHistoryArgs),
684 CrossRef(FilesCrossRefArgs),
686 Extract(FilesExtractArgs),
690}
691
692#[derive(Debug, Args, Clone, Default)]
693pub struct FileScopeArgs {
694 #[arg(long, value_enum)]
696 pub provider: Option<Provider>,
697 #[arg(long)]
700 pub session_id: Option<String>,
701 #[arg(long)]
705 pub path: Option<String>,
706}
707
708impl FileScopeArgs {
709 fn resolved_query(&self, db: &Db) -> Result<FileQuery> {
710 Ok(FileQuery {
711 provider: self.provider,
712 session_id: resolve_session_id(db, self.session_id.as_deref())?,
713 path_prefix: self.path.as_deref().map(crate::util::normalize_path_prefix),
714 ..Default::default()
715 })
716 }
717}
718
719#[derive(Debug, Args)]
720pub struct FilesSearchArgs {
721 #[arg(value_name = "PATTERN")]
724 pub pattern: Option<String>,
725 #[command(flatten)]
726 pub scope: FileScopeArgs,
727 #[arg(long)]
729 pub min_edits: Option<i64>,
730 #[arg(long)]
732 pub max_edits: Option<i64>,
733 #[command(flatten)]
734 pub dates: DateRange,
735 #[arg(long, default_value_t = 0)]
737 pub limit: usize,
738 #[arg(long, default_value_t = 0)]
740 pub offset: usize,
741 #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
743 pub format: OutputFormat,
744}
745
746#[derive(Debug, Args)]
747pub struct FilesHistoryArgs {
748 pub file: String,
750 #[command(flatten)]
751 pub scope: FileScopeArgs,
752 #[arg(long, default_value_t = 50)]
754 pub limit: usize,
755 #[arg(long, default_value_t = 0)]
757 pub offset: usize,
758 #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
760 pub format: OutputFormat,
761}
762
763#[derive(Debug, Args)]
764pub struct FilesCrossRefArgs {
765 #[arg(value_name = "PATTERN")]
768 pub pattern_arg: Option<String>,
769 #[arg(long)]
772 pub pattern: Option<String>,
773 #[command(flatten)]
774 pub scope: FileScopeArgs,
775 #[command(flatten)]
776 pub dates: DateRange,
777 #[arg(long, default_value_t = 0)]
779 pub limit: usize,
780 #[arg(long, default_value_t = 0)]
782 pub offset: usize,
783 #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
785 pub format: OutputFormat,
786}
787
788#[derive(Debug, Args)]
789pub struct FilesExtractArgs {
790 pub file: String,
792 #[arg(long, short, conflicts_with = "all")]
794 pub version: Option<usize>,
795 #[arg(long, conflicts_with = "restore")]
798 pub all: bool,
799 #[arg(long, value_enum, requires = "all")]
801 pub format: Option<ReconstructedVersionsFormat>,
802 #[command(flatten)]
803 pub scope: FileScopeArgs,
804 #[arg(long, conflicts_with = "output_dir")]
807 pub restore: bool,
808 #[arg(long, short)]
811 pub output_dir: Option<PathBuf>,
812 #[arg(long)]
814 pub dry_run: bool,
815}
816
817pub fn run(db: &Db, cmd: &FilesCmd) -> Result<()> {
818 match cmd {
819 FilesCmd::Search(args) => {
820 let (since, until) = args.dates.resolve_now()?;
821 let query = FileQuery {
822 pattern: args.pattern.clone(),
823 since,
824 until,
825 min_edits: args.min_edits,
826 max_edits: args.max_edits,
827 limit: args.limit,
828 offset: args.offset,
829 ..args.scope.resolved_query(db)?
830 };
831 emit(&db.file_search(&query)?, args.format)
832 }
833 FilesCmd::CrossRef(args) => {
834 let (since, until) = args.dates.resolve_now()?;
835 let pattern = match (&args.pattern_arg, &args.pattern) {
836 (Some(positional), Some(flag)) if positional != flag => {
837 anyhow::bail!(
838 "positional PATTERN and --pattern disagree: {positional:?} != {flag:?}"
839 );
840 }
841 (Some(positional), _) => Some(positional.clone()),
842 (_, Some(flag)) => Some(flag.clone()),
843 (None, None) => None,
844 };
845 let query = FileQuery {
846 pattern,
847 since,
848 until,
849 limit: args.limit,
850 offset: args.offset,
851 ..args.scope.resolved_query(db)?
852 };
853 emit(&db.file_cross_ref(&query)?, args.format)
854 }
855 FilesCmd::History(args) => {
856 let query = FileQuery {
857 limit: args.limit,
858 offset: args.offset,
859 ..args.scope.resolved_query(db)?
860 };
861 let versions = history(db, &args.file, &query)?;
862 emit(&versions, args.format)
863 }
864 FilesCmd::Extract(args) => run_extract(db, args),
865 }
866}
867
868fn resolve_session_id(db: &Db, session_id: Option<&str>) -> Result<Option<String>> {
869 session_id
870 .map(|id| db.resolve_session_record(id).map(|session| session.id))
871 .transpose()
872}
873
874fn file_edits_for_scope(
875 db: &Db,
876 file: &str,
877 scope: &FileScopeArgs,
878) -> Result<Vec<(String, Provider, FileEdit)>> {
879 let query = scope.resolved_query(db)?;
880 db.file_edits_for_query(file, &query)
881}
882
883fn run_extract(db: &Db, args: &FilesExtractArgs) -> Result<()> {
884 if args.all {
885 return run_extract_all(db, args);
886 }
887 let mut groups = group_by_session(file_edits_for_scope(db, &args.file, &args.scope)?);
888 let (session_id, provider, edits) = match groups.len() {
889 0 => bail!("no file edits found for '{}'", args.file),
890 1 => groups.remove(0),
891 n => {
892 let ids: Vec<String> = groups.into_iter().map(|(sid, _, _)| sid).collect();
893 bail!(
894 "'{}' was edited in {n} sessions ({}); pass --session-id with an exact id or unique prefix",
895 args.file,
896 ids.join(", ")
897 );
898 }
899 };
900
901 let version = args.version.unwrap_or(edits.len());
902 if version == 0 || version > edits.len() {
903 bail!(
904 "version {version} out of range for '{}' (1..={})",
905 args.file,
906 edits.len()
907 );
908 }
909 let content = reconstruct(&edits, version).ok_or_else(|| {
910 anyhow!(
911 "cannot reconstruct '{}' v{version}: no complete replay path exists (missing full snapshot or intervening path-only edit)",
912 args.file
913 )
914 })?;
915 let original_path = edits[version - 1].file_path.clone();
916 let original = PathBuf::from(&original_path);
917 let lines = count_lines(&content);
918
919 let writing = args.restore || args.output_dir.is_some();
921 if !writing {
922 if args.dry_run {
923 println!(
924 "session {session_id}: '{}' v{version}/{} ({lines} lines) — dry run, not printed",
925 args.file,
926 edits.len()
927 );
928 return Ok(());
929 }
930 let stdout = io::stdout();
931 let mut out = stdout.lock();
932 out.write_all(content.as_bytes())?;
933 if !content.ends_with('\n') {
934 writeln!(out)?;
935 }
936 return Ok(());
937 }
938
939 let base = match &args.output_dir {
941 Some(dir) => dir.join(safe_output_name(&original, &args.file)),
942 None => {
943 ensure_safe_restore_target(&original)?;
945 original.clone()
946 }
947 };
948 let target = restore_target(&base, |path| path.exists());
949
950 if args.dry_run {
951 println!(
952 "session {session_id}: would restore '{}' v{version}/{} ({lines} lines) -> {}",
953 args.file,
954 edits.len(),
955 target.display()
956 );
957 return Ok(());
958 }
959 let reconstructed = ReconstructedFile {
960 session_id,
961 provider,
962 version,
963 file_path: original_path,
964 content,
965 };
966 let target = restore_reconstructed(&reconstructed, args.output_dir.as_deref())?;
967 println!(
968 "{}",
969 restored_file_receipt(
970 &args.file,
971 &reconstructed.session_id,
972 version,
973 edits.len(),
974 lines,
975 &target,
976 reconstructed.content.as_bytes(),
977 )
978 );
979 Ok(())
980}
981
982fn restored_file_receipt(
987 requested_file: &str,
988 source_session_id: &str,
989 version: usize,
990 version_count: usize,
991 lines: i64,
992 destination: &Path,
993 content: &[u8],
994) -> String {
995 let digest = crate::hashing::sha256(content);
996 format!(
997 "restored '{requested_file}' from session {source_session_id} v{version}/{version_count} ({lines} lines) -> {} (sha256:{digest})",
998 destination.display()
999 )
1000}
1001
1002fn run_extract_all(db: &Db, args: &FilesExtractArgs) -> Result<()> {
1003 let query = args.scope.resolved_query(db)?;
1004 let versions = reconstruct_versions_query(db, &args.file, &query)?;
1005
1006 if let Some(output_dir) = &args.output_dir {
1007 let destination = if output_dir.is_absolute() {
1008 output_dir.clone()
1009 } else {
1010 std::env::current_dir()
1011 .context("failed to resolve current directory for --output-dir")?
1012 .join(output_dir)
1013 };
1014 recovery_publication_parent(&destination)?;
1015 if args.dry_run {
1016 let count = versions.count();
1017 eprintln!(
1018 "would atomically publish {count} reconstructable versions of '{}' to {}",
1019 args.file,
1020 destination.display()
1021 );
1022 return Ok(());
1023 }
1024 let receipt = publish_reconstructed_versions(versions, &destination)?;
1025 eprintln!(
1026 "published {} reconstructable versions of '{}' to {}",
1027 receipt.files.len(),
1028 args.file,
1029 receipt.destination.display()
1030 );
1031 for published in &receipt.files {
1034 println!(
1035 " v{} from session {} -> {} (sha256:{})",
1036 published.version,
1037 published.source_session_id,
1038 published.path.display(),
1039 published.sha256
1040 );
1041 }
1042 return Ok(());
1043 }
1044
1045 if args.dry_run {
1046 let count = versions.count();
1047 eprintln!(
1048 "would stream {count} reconstructable versions of '{}' to stdout",
1049 args.file
1050 );
1051 return Ok(());
1052 }
1053
1054 let stdout = io::stdout();
1055 let mut out = stdout.lock();
1056 write_reconstructed_versions(
1057 versions,
1058 &args.file,
1059 args.format.unwrap_or_default(),
1060 &mut out,
1061 )?;
1062 out.flush()?;
1063 Ok(())
1064}
1065
1066#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
1067pub enum ReconstructedVersionsFormat {
1068 #[default]
1069 Framed,
1070 Jsonl,
1071}
1072
1073fn write_reconstructed_versions<I, W>(
1074 versions: I,
1075 requested_file: &str,
1076 format: ReconstructedVersionsFormat,
1077 out: &mut W,
1078) -> Result<usize>
1079where
1080 I: IntoIterator<Item = ReconstructedFile>,
1081 W: io::Write,
1082{
1083 let escaped_file = requested_file.escape_default().to_string();
1084 let mut count = 0;
1085 for reconstructed in versions {
1086 match format {
1087 ReconstructedVersionsFormat::Framed => {
1088 if count > 0 {
1089 writeln!(out)?;
1090 }
1091 writeln!(
1092 out,
1093 "=== {escaped_file} v{} session={} provider={} ===",
1094 reconstructed.version,
1095 reconstructed.session_id.escape_default(),
1096 reconstructed.provider.as_str()
1097 )?;
1098 out.write_all(reconstructed.content.as_bytes())?;
1099 if !reconstructed.content.ends_with('\n') {
1100 writeln!(out)?;
1101 }
1102 }
1103 ReconstructedVersionsFormat::Jsonl => {
1104 serde_json::to_writer(&mut *out, &reconstructed)
1105 .context("failed to serialize a reconstructed version as JSONL")?;
1106 writeln!(out)?;
1107 }
1108 }
1109 count += 1;
1110 }
1111 Ok(count)
1112}
1113
1114fn emit<T: Serialize + Row>(rows: &[T], format: OutputFormat) -> Result<()> {
1115 let stdout = io::stdout();
1116 let mut out = stdout.lock();
1117 render(rows, format, &mut out)?;
1118 out.flush()?;
1119 Ok(())
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124 use super::*;
1125 use clap::Parser;
1126
1127 #[derive(Parser)]
1128 struct TestCli {
1129 #[command(subcommand)]
1130 cmd: FilesCmd,
1131 }
1132
1133 fn write(seq: i64, content: &str) -> FileEdit {
1134 FileEdit {
1135 seq,
1136 ts: None,
1137 tool: "Write".into(),
1138 file_path: "/repo/src/db.rs".into(),
1139 file_name: "db.rs".into(),
1140 new_content: Some(content.into()),
1141 edits: Vec::new(),
1142 }
1143 }
1144
1145 fn recon(version: usize, content: &str) -> ReconstructedFile {
1146 ReconstructedFile {
1147 session_id: "claude:s1".to_string(),
1148 provider: Provider::Claude,
1149 version,
1150 file_path: "src/a.rs".to_string(),
1151 content: content.to_string(),
1152 }
1153 }
1154
1155 #[test]
1156 fn write_reconstructed_versions_framed_and_jsonl_serialize_each_version() {
1157 let versions = || vec![recon(1, "first content"), recon(2, "second content")];
1158
1159 let mut buf = Vec::new();
1162 let n = write_reconstructed_versions(
1163 versions(),
1164 "src/a.rs",
1165 ReconstructedVersionsFormat::Framed,
1166 &mut buf,
1167 )
1168 .unwrap();
1169 assert_eq!(n, 2);
1170 let framed = String::from_utf8(buf).unwrap();
1171 assert!(framed.contains("=== src/a.rs v1 session=claude:s1 provider=claude ==="));
1172 assert!(framed.contains("=== src/a.rs v2 session=claude:s1 provider=claude ==="));
1173 assert!(framed.contains("first content\n"));
1174 assert!(framed.contains("\n\n=== src/a.rs v2")); let mut buf = Vec::new();
1178 let n = write_reconstructed_versions(
1179 versions(),
1180 "src/a.rs",
1181 ReconstructedVersionsFormat::Jsonl,
1182 &mut buf,
1183 )
1184 .unwrap();
1185 assert_eq!(n, 2);
1186 let jsonl = String::from_utf8(buf).unwrap();
1187 let lines: Vec<&str> = jsonl.lines().collect();
1188 assert_eq!(lines.len(), 2);
1189 for line in lines {
1190 let v: serde_json::Value = serde_json::from_str(line).unwrap();
1191 assert_eq!(v["session_id"], "claude:s1");
1192 assert!(v["version"].is_number());
1193 assert!(v["content"].is_string());
1194 }
1195 }
1196
1197 #[test]
1198 fn create_recovery_file_persists_content_and_drop_removes_unpersisted() {
1199 let dir = tempfile::tempdir().unwrap();
1200 let base = dir.path().join("recovered.txt");
1201
1202 let pending = create_recovery_file(&base).unwrap();
1205 assert_eq!(
1206 pending.path.file_name().unwrap().to_str().unwrap(),
1207 "recovered.recovered.txt"
1208 );
1209 let path = pending.persist(b"hello").unwrap();
1210 assert_eq!(std::fs::read(&path).unwrap(), b"hello");
1211
1212 let pending2 = create_recovery_file(&base).unwrap();
1215 assert_eq!(
1216 pending2.path.file_name().unwrap().to_str().unwrap(),
1217 "recovered.recovered_2.txt"
1218 );
1219 let reserved = pending2.path.clone();
1220 assert!(reserved.exists());
1221 drop(pending2);
1222 assert!(!reserved.exists());
1223 }
1224
1225 fn edit(seq: i64, pairs: &[(&str, &str)]) -> FileEdit {
1226 FileEdit {
1227 seq,
1228 ts: None,
1229 tool: if pairs.len() > 1 { "MultiEdit" } else { "Edit" }.into(),
1230 file_path: "/repo/src/db.rs".into(),
1231 file_name: "db.rs".into(),
1232 new_content: None,
1233 edits: pairs.iter().map(|(o, n)| EditOp::new(*o, *n)).collect(),
1234 }
1235 }
1236
1237 fn path_only(seq: i64) -> FileEdit {
1238 FileEdit {
1239 seq,
1240 ts: None,
1241 tool: "apply_patch".into(),
1242 file_path: "/repo/src/db.rs".into(),
1243 file_name: "db.rs".into(),
1244 new_content: None,
1245 edits: Vec::new(),
1246 }
1247 }
1248
1249 fn edit_all(seq: i64, pairs: &[(&str, &str)]) -> FileEdit {
1250 FileEdit {
1251 seq,
1252 ts: None,
1253 tool: if pairs.len() > 1 { "MultiEdit" } else { "Edit" }.into(),
1254 file_path: "/repo/src/db.rs".into(),
1255 file_name: "db.rs".into(),
1256 new_content: None,
1257 edits: pairs
1258 .iter()
1259 .map(|(o, n)| EditOp {
1260 old: (*o).into(),
1261 new: (*n).into(),
1262 replace_all: true,
1263 })
1264 .collect(),
1265 }
1266 }
1267
1268 #[test]
1269 fn cross_ref_accepts_positional_pattern_and_pattern_option() {
1270 assert!(
1271 TestCli::try_parse_from(["sg", "cross-ref", "Glossary-and-Definitions.md"]).is_ok()
1272 );
1273 assert!(TestCli::try_parse_from([
1274 "sg",
1275 "cross-ref",
1276 "--pattern",
1277 "Glossary-and-Definitions.md",
1278 ])
1279 .is_ok());
1280 }
1281
1282 #[test]
1283 fn cross_ref_rejects_disagreeing_positional_and_pattern_option() {
1284 let dir = tempfile::tempdir().unwrap();
1285 let db = Db::open(&dir.path().join("index.db")).unwrap();
1286 let args = FilesCrossRefArgs {
1287 pattern_arg: Some("a.rs".into()),
1288 pattern: Some("b.rs".into()),
1289 scope: FileScopeArgs::default(),
1290 dates: DateRange::default(),
1291 limit: 0,
1292 offset: 0,
1293 format: OutputFormat::Json,
1294 };
1295 assert!(run(&db, &FilesCmd::CrossRef(args)).is_err());
1296 }
1297
1298 #[test]
1299 fn file_commands_accept_exact_session_id_scope() {
1300 for args in [
1301 vec!["sg", "search", "--session-id", "claude:abc"],
1302 vec!["sg", "cross-ref", "--session-id", "claude:abc"],
1303 vec!["sg", "history", "db.rs", "--session-id", "claude:abc"],
1304 vec!["sg", "extract", "db.rs", "--session-id", "claude:abc"],
1305 ] {
1306 assert!(TestCli::try_parse_from(args).is_ok());
1307 }
1308 }
1309
1310 #[test]
1311 fn file_commands_accept_provider_and_path_scope() {
1312 for args in [
1313 vec!["sg", "search", "--provider", "claude", "--path", "/repo"],
1314 vec!["sg", "cross-ref", "--provider", "codex", "--path", "/repo"],
1315 vec![
1316 "sg",
1317 "history",
1318 "db.rs",
1319 "--provider",
1320 "cursor",
1321 "--path",
1322 "/repo",
1323 ],
1324 vec![
1325 "sg",
1326 "extract",
1327 "db.rs",
1328 "--provider",
1329 "pi",
1330 "--path",
1331 "/repo",
1332 ],
1333 ] {
1334 assert!(TestCli::try_parse_from(args).is_ok());
1335 }
1336 }
1337
1338 #[test]
1339 fn file_commands_reject_removed_substring_session_scope() {
1340 assert!(TestCli::try_parse_from(["sg", "search", "--session", "abc"]).is_err());
1341 assert!(TestCli::try_parse_from(["sg", "extract", "db.rs", "--session", "abc",]).is_err());
1342 }
1343
1344 #[test]
1345 fn extract_all_has_unambiguous_destination_and_version_options() {
1346 assert!(TestCli::try_parse_from(["sg", "extract", "db.rs", "--all"]).is_ok());
1347 assert!(TestCli::try_parse_from([
1348 "sg",
1349 "extract",
1350 "db.rs",
1351 "--all",
1352 "--output-dir",
1353 "versions",
1354 ])
1355 .is_ok());
1356 assert!(
1357 TestCli::try_parse_from(["sg", "extract", "db.rs", "--all", "--format", "jsonl",])
1358 .is_ok()
1359 );
1360 for args in [
1361 vec!["sg", "extract", "db.rs", "--all", "--version", "2"],
1362 vec!["sg", "extract", "db.rs", "--all", "--restore"],
1363 vec!["sg", "extract", "db.rs", "--restore", "--output-dir", "out"],
1364 vec!["sg", "extract", "db.rs", "--format", "jsonl"],
1365 ] {
1366 assert!(TestCli::try_parse_from(args).is_err());
1367 }
1368 }
1369
1370 #[test]
1371 fn framed_version_stream_preserves_content_and_original_version_gaps() {
1372 let versions = vec![
1373 ReconstructedFile {
1374 session_id: "claude:one".into(),
1375 provider: Provider::Claude,
1376 version: 1,
1377 file_path: "/repo/db.rs".into(),
1378 content: "one\n".into(),
1379 },
1380 ReconstructedFile {
1381 session_id: "claude:one".into(),
1382 provider: Provider::Claude,
1383 version: 3,
1384 file_path: "/repo/db.rs".into(),
1385 content: "three".into(),
1386 },
1387 ];
1388 let mut output = Vec::new();
1389 assert_eq!(
1390 write_reconstructed_versions(
1391 versions,
1392 "db\n.rs",
1393 ReconstructedVersionsFormat::Framed,
1394 &mut output,
1395 )
1396 .unwrap(),
1397 2
1398 );
1399 assert_eq!(
1400 String::from_utf8(output).unwrap(),
1401 "=== db\\n.rs v1 session=claude:one provider=claude ===\none\n\n=== db\\n.rs v3 session=claude:one provider=claude ===\nthree\n"
1402 );
1403 }
1404
1405 #[test]
1406 fn jsonl_version_stream_is_unambiguous_and_round_trips_content() {
1407 let version = ReconstructedFile {
1408 session_id: "claude:one".into(),
1409 provider: Provider::Claude,
1410 version: 2,
1411 file_path: "/repo/db.rs".into(),
1412 content: "=== db.rs v9 ===\nembedded header\n".into(),
1413 };
1414 let mut output = Vec::new();
1415 assert_eq!(
1416 write_reconstructed_versions(
1417 [version.clone()],
1418 "ignored.rs",
1419 ReconstructedVersionsFormat::Jsonl,
1420 &mut output,
1421 )
1422 .unwrap(),
1423 1
1424 );
1425 assert_eq!(
1426 serde_json::from_slice::<ReconstructedFile>(&output).unwrap(),
1427 version
1428 );
1429 }
1430
1431 #[test]
1432 fn recovery_publication_rejects_empty_and_duplicate_versions_without_residue() {
1433 let dir = tempfile::tempdir().unwrap();
1434 let destination = dir.path().join("versions");
1435 let empty_error =
1436 publish_reconstructed_versions(std::iter::empty::<ReconstructedFile>(), &destination)
1437 .unwrap_err();
1438 assert!(empty_error.to_string().contains("empty"));
1439 assert_eq!(dir.path().read_dir().unwrap().count(), 0);
1440
1441 let duplicate = ReconstructedFile {
1442 session_id: "claude:one".into(),
1443 provider: Provider::Claude,
1444 version: 1,
1445 file_path: "/repo/db.rs".into(),
1446 content: "complete".into(),
1447 };
1448 let duplicate_error =
1449 publish_reconstructed_versions([duplicate.clone(), duplicate], &destination)
1450 .unwrap_err();
1451 assert!(duplicate_error.to_string().contains("staged artifact"));
1452 assert_eq!(dir.path().read_dir().unwrap().count(), 0);
1453 }
1454
1455 #[test]
1456 fn reconstruct_replace_all_replaces_every_occurrence() {
1457 let edits = vec![
1459 write(0, "foo bar foo baz foo"),
1460 edit_all(1, &[("foo", "X")]),
1461 ];
1462 assert_eq!(reconstruct(&edits, 2).as_deref(), Some("X bar X baz X"));
1463 }
1464
1465 #[test]
1466 fn reconstruct_edit_without_replace_all_replaces_first_only() {
1467 let edits = vec![write(0, "foo bar foo"), edit(1, &[("foo", "X")])];
1470 assert_eq!(reconstruct(&edits, 2).as_deref(), Some("X bar foo"));
1471 }
1472
1473 #[test]
1474 fn reconstruct_replays_write_then_edit() {
1475 let edits = vec![write(0, "a\nb\nc"), edit(1, &[("b", "B")])];
1476 assert_eq!(reconstruct(&edits, 1).as_deref(), Some("a\nb\nc"));
1477 assert_eq!(reconstruct(&edits, 2).as_deref(), Some("a\nB\nc"));
1478 }
1479
1480 #[test]
1481 fn reconstruct_all_versions_skips_pre_snapshot_deltas_and_replays_once() {
1482 let edits = vec![
1483 edit(0, &[("missing", "ignored")]),
1484 write(1, "alpha"),
1485 edit(2, &[("alpha", "beta")]),
1486 write(3, "reset"),
1487 ];
1488 let versions = ReconstructedFileVersions {
1489 session_id: "claude:a".into(),
1490 provider: Provider::Claude,
1491 edits: edits.into_iter(),
1492 content: None,
1493 version: 0,
1494 }
1495 .collect::<Vec<_>>();
1496 assert_eq!(
1497 versions
1498 .iter()
1499 .map(|version| (version.version, version.content.as_str()))
1500 .collect::<Vec<_>>(),
1501 [(2, "alpha"), (3, "beta"), (4, "reset")]
1502 );
1503 }
1504
1505 #[test]
1506 fn path_only_edit_invalidates_reconstruction_until_next_snapshot() {
1507 let edits = vec![
1508 write(0, "known"),
1509 path_only(1),
1510 edit(2, &[("known", "cannot be trusted")]),
1511 write(3, "reset"),
1512 ];
1513
1514 assert_eq!(reconstruct(&edits, 1).as_deref(), Some("known"));
1515 assert_eq!(reconstruct(&edits, 2), None);
1516 assert_eq!(reconstruct(&edits, 3), None);
1517 assert_eq!(reconstruct(&edits, 4).as_deref(), Some("reset"));
1518 assert_eq!(version_line_counts(&edits), [1, 0, 0, 1]);
1519 let versions = ReconstructedFileVersions {
1520 session_id: "codex:a".into(),
1521 provider: Provider::Codex,
1522 edits: edits.into_iter(),
1523 content: None,
1524 version: 0,
1525 }
1526 .collect::<Vec<_>>();
1527 assert_eq!(
1528 versions
1529 .iter()
1530 .map(|version| (version.version, version.content.as_str()))
1531 .collect::<Vec<_>>(),
1532 [(1, "known"), (4, "reset")]
1533 );
1534 }
1535
1536 #[test]
1537 fn reconstruct_replays_multiedit() {
1538 let edits = vec![write(0, "x y z"), edit(1, &[("x", "1"), ("z", "9")])];
1539 assert_eq!(reconstruct(&edits, 2).as_deref(), Some("1 y 9"));
1540 }
1541
1542 #[test]
1543 fn reconstruct_uses_latest_write_as_base() {
1544 let edits = vec![
1546 write(0, "old content"),
1547 write(1, "fresh\ncontent"),
1548 edit(2, &[("fresh", "FRESH")]),
1549 ];
1550 assert_eq!(reconstruct(&edits, 3).as_deref(), Some("FRESH\ncontent"));
1551 }
1552
1553 #[test]
1554 fn reconstruct_without_write_base_is_none() {
1555 let edits = vec![edit(0, &[("a", "b")])];
1557 assert_eq!(reconstruct(&edits, 1), None);
1558 }
1559
1560 #[test]
1561 fn reconstruct_out_of_range_is_none() {
1562 let edits = vec![write(0, "x")];
1563 assert_eq!(reconstruct(&edits, 0), None);
1564 assert_eq!(reconstruct(&edits, 2), None);
1565 }
1566
1567 #[test]
1568 fn reconstruct_missing_old_string_invalidates_until_the_next_full_snapshot() {
1569 let edits = vec![write(0, "a\nb"), edit(1, &[("zzz", "Z")])];
1570 assert_eq!(
1571 reconstruct(&edits, 2),
1572 None,
1573 "a replay mismatch must not publish bytes that were never proven to exist"
1574 );
1575 }
1576
1577 #[test]
1578 fn restore_target_avoids_collisions() {
1579 let original = Path::new("/repo/src/db.rs");
1580 let first = restore_target(original, |_| false);
1582 assert_eq!(first, Path::new("/repo/src/db.recovered.rs"));
1583 let taken = Path::new("/repo/src/db.recovered.rs");
1585 let second = restore_target(original, |p| p == taken);
1586 assert_eq!(second, Path::new("/repo/src/db.recovered_2.rs"));
1587 }
1588
1589 #[test]
1590 fn restore_rejects_parent_dir_traversal() {
1591 assert!(ensure_safe_restore_target(Path::new("../../etc/cron.d/evil")).is_err());
1593 assert!(ensure_safe_restore_target(Path::new("/repo/../../etc/x")).is_err());
1594 assert!(ensure_safe_restore_target(Path::new("/Users/me/proj/src/db.rs")).is_ok());
1596 assert!(ensure_safe_restore_target(Path::new("src/db.rs")).is_ok());
1597 }
1598
1599 #[test]
1600 fn restored_receipt_names_destination_and_content_checksum() {
1601 let receipt = restored_file_receipt(
1602 "src/lib.rs",
1603 "claude:source-session",
1604 2,
1605 4,
1606 3,
1607 Path::new("/repo/src/lib.recovered.rs"),
1608 b"one\ntwo\nthree\n",
1609 );
1610 assert_eq!(
1611 receipt,
1612 format!(
1613 "restored 'src/lib.rs' from session claude:source-session v2/4 (3 lines) -> /repo/src/lib.recovered.rs (sha256:{})",
1614 crate::hashing::sha256(b"one\ntwo\nthree\n")
1615 )
1616 );
1617 }
1618
1619 #[test]
1620 fn safe_output_name_cannot_escape_output_dir() {
1621 assert_eq!(
1623 safe_output_name(Path::new("/home/u/proj/src/main.rs"), "main.rs"),
1624 PathBuf::from("main.rs")
1625 );
1626 assert_eq!(
1628 safe_output_name(Path::new("foo/.."), "evil.rs"),
1629 PathBuf::from("evil.rs")
1630 );
1631 assert_eq!(
1633 safe_output_name(Path::new(".."), "../../etc/passwd"),
1634 PathBuf::from("passwd")
1635 );
1636 assert_eq!(
1638 safe_output_name(Path::new(".."), ".."),
1639 PathBuf::from("recovered")
1640 );
1641 let joined = Path::new("/out").join(safe_output_name(Path::new(".."), "../../etc/passwd"));
1643 assert_eq!(joined, PathBuf::from("/out/passwd"));
1644 }
1645
1646 #[test]
1647 fn restore_target_handles_no_extension() {
1648 let original = Path::new("/repo/Makefile");
1649 assert_eq!(
1650 restore_target(original, |_| false),
1651 Path::new("/repo/Makefile.recovered")
1652 );
1653 }
1654
1655 #[test]
1656 fn incremental_line_counts_match_per_version_reconstruct() {
1657 let edits = vec![
1660 write(0, "a\nb\nc"),
1661 edit(1, &[("b", "B")]),
1662 write(2, "x"),
1663 edit(3, &[("x", "X\nY\nZ")]),
1664 ];
1665 let incremental = version_line_counts(&edits);
1666 let reference: Vec<i64> = (1..=edits.len())
1667 .map(|v| reconstruct(&edits, v).map(|c| count_lines(&c)).unwrap_or(0))
1668 .collect();
1669 assert_eq!(incremental, reference);
1670 assert_eq!(incremental, vec![3, 3, 1, 3]);
1671 }
1672
1673 #[test]
1674 fn version_line_counts_zero_before_first_write() {
1675 let edits = vec![
1677 edit(0, &[("a", "b")]),
1678 write(1, "one\ntwo"),
1679 edit(2, &[("one", "1")]),
1680 ];
1681 assert_eq!(version_line_counts(&edits), vec![0, 2, 2]);
1682 }
1683
1684 #[test]
1685 fn group_by_session_numbers_versions_in_order() {
1686 let rows = vec![
1687 ("claude:a".into(), Provider::Claude, write(0, "v1")),
1688 (
1689 "claude:a".into(),
1690 Provider::Claude,
1691 edit(1, &[("v1", "v2")]),
1692 ),
1693 ("claude:b".into(), Provider::Claude, write(0, "other")),
1694 ];
1695 let groups = group_by_session(rows);
1696 assert_eq!(groups.len(), 2);
1697 assert_eq!(groups[0].0, "claude:a");
1698 assert_eq!(groups[0].2.len(), 2, "session a has two ordered versions");
1699 assert_eq!(groups[1].0, "claude:b");
1700 assert_eq!(groups[1].2.len(), 1);
1701 }
1702}