Skip to main content

ai_session_search/
files.rs

1// SPDX-FileCopyrightText: 2026 Andrew Hundt
2// SPDX-License-Identifier: Apache-2.0
3
4//! `files` command group: file-version recovery from session tool calls.
5//!
6//! File-mutating tool calls are extracted per provider and persisted to the `file_edits`
7//! table, with per-provider fidelity:
8//!   * claude — `Write`/`Edit`/`MultiEdit`/`NotebookEdit` (full content + `old`→`new` deltas);
9//!   * pi     — `write`/`edit` (full content + `old`→`new` deltas);
10//!   * codex  — `apply_patch` (`Add File` = full content; `Update`/`Delete` = path-only);
11//!   * cursor — `ApplyPatch` unified diff (path-only);
12//!   * antigravity — `write_to_file`/`replace_file_content`/`multi_replace_file_content`
13//!     (path-only; the transcript's edit-arg content shape is unverified upstream).
14//!
15//! Path-only edits appear in `files search`/`history`/`cross-ref` but are not
16//! reconstructable via `files extract` (a diff/hunk is not a replayable Write/Edit delta).
17//!
18//! This module turns the recorded edits into:
19//!   * `files search`   — which files were edited, how often, across how many sessions;
20//!   * `files history`   — the ordered versions of one file (with reconstructed line counts);
21//!   * `files cross-ref` — the file ↔ session linkage;
22//!   * `files extract`   — reconstruct (and optionally restore) a historical version.
23//!
24//! `files history` numbers versions by record order within the session (the stable, causal
25//! ordering). That is normally — but not strictly — chronological: in a forked/resumed
26//! session a later version's `ts` can predate an earlier one. This is expected, not a bug.
27//!
28//! Reconstruction replays deltas from the most recent full `Write` snapshot. `extract
29//! --restore` never overwrites: it writes to a
30//! collision-safe `<stem>.recovered[.ext]` sibling.
31
32use 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    // One parse owns this O(P) state, where P is unresolved mutation calls. Stage/finish are
53    // average O(1); dropping the tracker at EOF discards every outcome that was never proven.
54    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
131/// Reconstruct a file's content as of 1-based `version` by replaying edits forward
132/// from the most recent full `Write` snapshot at or before the target.
133///
134/// Returns `None` when no complete replay path exists at `version` or when `version` is out of
135/// range. A path-only edit or replay mismatch invalidates known content until a later full
136/// snapshot; recovery never invents a successful file state from an unapplied mutation.
137pub 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    // Latest full snapshot (a `Write`, which sets `new_content`) at or before target.
143    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
153/// Apply replacements in order. Returns false when any requested replacement cannot be replayed.
154fn 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
173/// Advance one reconstruction state without inventing bytes for an unreplayable event.
174/// A later full snapshot re-establishes a known state after any path-only gap.
175fn 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
187/// Reject a `--restore` destination derived from session data that would escape the
188/// intended tree via a `..` (parent-dir) component. Reconstructed `file_path`s come
189/// straight from (potentially untrusted) session JSON; an absolute path to the user's
190/// own file is the normal case (we restore beside it, never overwriting), but a parent-dir
191/// traversal is never a legitimate recorded edit path and must not steer a write elsewhere.
192fn 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
204/// The bare, traversal-safe filename to write under `--output-dir`. The result is always
205/// a single path component: [`Path::file_name`] strips any directory and returns `None`
206/// for a path ending in `..`, so neither a session-recorded path nor a `--file` argument
207/// containing `../` (or an absolute path) can escape the chosen output directory when it
208/// is `dir.join`ed. Falls back to the literal `recovered` if neither source yields a name.
209fn 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
217/// Pick a candidate recovery path: `<stem>.recovered[.ext]`, then `_2`, `_3`, …
218/// until `exists` returns false. Publication must still use `create_new`; this helper
219/// remains private so external callers cannot mistake candidate selection for a lock.
220fn 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
306/// Restore a reconstructed file without overwriting an existing path.
307///
308/// When `output_dir` is absent, the session-recorded path is validated and a
309/// collision-safe `.recovered` sibling is created. When it is present, only the
310/// recorded basename is used beneath that directory. The destination path is
311/// atomically claimed with `create_new`, and a partial file is removed by an RAII
312/// guard if writing or syncing fails.
313pub 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
331/// Group `(session_id, provider, edit)` rows (already ordered by `(session_id, seq)`)
332/// into per-session edit lists, preserving order. Each list's index+1 is its version.
333fn 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
350/// Line count of every 1-based version, reconstructed in a single forward pass:
351/// O(total edits × content) instead of the O(versions² × content) you get from
352/// calling [`reconstruct`] per version (which re-replays from the base each time).
353/// A `Write` resets the running content; deltas replay on top; versions before the
354/// first `Write` count 0 (no full-content base) — identical to `reconstruct`.
355fn 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/// One published version: which session and version it was reconstructed from, where it landed,
376/// and the SHA-256 of the exact bytes written there.
377///
378/// A recovered file is evidence about a session, so the receipt has to say which session and which
379/// version of the file it came from. Reporting only the destination path left a directory of
380/// `name_vN` files whose provenance could be re-derived only by searching the index again, while
381/// the single-version restore beside it had always printed all four facts.
382#[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/// Receipt for an atomically published, non-replacing directory of reconstructed versions.
391#[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
445/// Publish reconstructed versions as one complete directory transaction.
446///
447/// The destination must be absolute, its parent must already exist, and no entry may already
448/// occupy it. Each version is written and synced in a same-parent staging directory before an
449/// atomic no-replace rename makes the complete set visible. Dropping after any earlier failure
450/// removes the staging directory.
451pub 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
484/// Reconstruct one selected version without performing filesystem I/O.
485pub 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/// A lazy, causal reconstruction of every version with a complete replay path.
534///
535/// The iterator owns the selected edit rows, so it does not retain a database borrow or lock.
536/// Versions before the first full snapshot and versions invalidated by path-only edits are
537/// omitted; their original version numbers remain visible as gaps. Replay work is linear in the
538/// edit sequence and live content memory is proportional to one recovered version.
539#[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
575/// Prepare lazy reconstruction without performing filesystem I/O.
576pub 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
596/// Return every reconstructable or path-only file version grouped by session.
597/// Version numbers are 1-based and preserve provider event order.
598pub 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    /// List files edited via tool calls, with edit/session counts.
679    Search(FilesSearchArgs),
680    /// Show the ordered versions of one file (per session).
681    ///
682    /// Use `aise files extract` to reconstruct one of these versions, or `aise files search` to find the path.
683    History(FilesHistoryArgs),
684    /// Show which sessions edited which files.
685    CrossRef(FilesCrossRefArgs),
686    /// Reconstruct (and optionally restore) a historical version of a file.
687    ///
688    /// Use `aise files history` first to pick the version you want.
689    Extract(FilesExtractArgs),
690}
691
692#[derive(Debug, Args, Clone, Default)]
693pub struct FileScopeArgs {
694    /// Restrict to one indexed session source. Omit to include all nine.
695    #[arg(long, value_enum)]
696    pub provider: Option<Provider>,
697    /// Exact session id or unique prefix. Use this when chaining from session/message output.
698    /// Omit to include every session.
699    #[arg(long)]
700    pub session_id: Option<String>,
701    /// Restrict to sessions whose cwd, repo root, or transcript path is this directory or a
702    /// descendant of it (component boundary).
703    /// Omit to search every allowed root.
704    #[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    /// Glob over the basename (`*.rs`), or the full path when it contains `/`. Omit to list
722    /// every edited file.
723    #[arg(value_name = "PATTERN")]
724    pub pattern: Option<String>,
725    #[command(flatten)]
726    pub scope: FileScopeArgs,
727    /// Only files with at least this many edits. Omit for no lower bound on edit count.
728    #[arg(long)]
729    pub min_edits: Option<i64>,
730    /// Only files with at most this many edits. Omit for no upper bound on edit count.
731    #[arg(long)]
732    pub max_edits: Option<i64>,
733    #[command(flatten)]
734    pub dates: DateRange,
735    /// Max results. 0 = unlimited.
736    #[arg(long, default_value_t = 0)]
737    pub limit: usize,
738    /// Skip this many rows in deterministic result order.
739    #[arg(long, default_value_t = 0)]
740    pub offset: usize,
741    /// Output format.
742    #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
743    pub format: OutputFormat,
744}
745
746#[derive(Debug, Args)]
747pub struct FilesHistoryArgs {
748    /// File basename or path (e.g. `db.rs` or `src/db.rs`).
749    pub file: String,
750    #[command(flatten)]
751    pub scope: FileScopeArgs,
752    /// Max versions to return. 0 = unlimited.
753    #[arg(long, default_value_t = 50)]
754    pub limit: usize,
755    /// Skip this many versions in deterministic session/version order.
756    #[arg(long, default_value_t = 0)]
757    pub offset: usize,
758    /// Output format.
759    #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
760    pub format: OutputFormat,
761}
762
763#[derive(Debug, Args)]
764pub struct FilesCrossRefArgs {
765    /// Glob over the basename, or the full path when it contains `/`. Omit to cross-reference
766    /// every edited file.
767    #[arg(value_name = "PATTERN")]
768    pub pattern_arg: Option<String>,
769    /// Glob over the basename, or the full path when it contains `/`. Omit to cross-reference
770    /// every edited file.
771    #[arg(long)]
772    pub pattern: Option<String>,
773    #[command(flatten)]
774    pub scope: FileScopeArgs,
775    #[command(flatten)]
776    pub dates: DateRange,
777    /// Max results. 0 = unlimited.
778    #[arg(long, default_value_t = 0)]
779    pub limit: usize,
780    /// Skip this many rows in deterministic result order.
781    #[arg(long, default_value_t = 0)]
782    pub offset: usize,
783    /// Output format.
784    #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
785    pub format: OutputFormat,
786}
787
788#[derive(Debug, Args)]
789pub struct FilesExtractArgs {
790    /// File basename or path to reconstruct.
791    pub file: String,
792    /// 1-based version to reconstruct. Default = latest.
793    #[arg(long, short, conflicts_with = "all")]
794    pub version: Option<usize>,
795    /// Reconstruct every version with a complete replay path. Streams versions to stdout, or
796    /// atomically publishes a new non-replacing directory when --output-dir is present.
797    #[arg(long, conflicts_with = "restore")]
798    pub all: bool,
799    /// Multi-version stdout format. Valid only with --all. Default: framed.
800    #[arg(long, value_enum, requires = "all")]
801    pub format: Option<ReconstructedVersionsFormat>,
802    #[command(flatten)]
803    pub scope: FileScopeArgs,
804    /// Write the reconstructed content to a collision-safe `.recovered` sibling
805    /// (never overwrites) instead of printing to stdout.
806    #[arg(long, conflicts_with = "output_dir")]
807    pub restore: bool,
808    /// For one version, a directory to write into. With --all, the new directory to atomically
809    /// publish; it must not already exist. Omit to write to the current directory.
810    #[arg(long, short)]
811    pub output_dir: Option<PathBuf>,
812    /// Report what would happen without printing content or writing files.
813    #[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    // Decide whether we are writing a file or printing to stdout.
920    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    // Build a collision-safe target (never overwrites an existing file).
940    let base = match &args.output_dir {
941        Some(dir) => dir.join(safe_output_name(&original, &args.file)),
942        None => {
943            // In-place restore beside the session-recorded original: reject `..` escapes.
944            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
982/// Render one successful recovery receipt with a digest of the exact published bytes.
983/// For `B` content bytes this adds O(B) SHA-256 work, O(1) hashing state, and one serial in-memory
984/// pass after reconstruction/publication; filesystem write complexity and collision safety do not
985/// change.
986fn 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        // One line per version, carrying the same evidence a single-version restore prints, so a
1032        // published directory can be audited without querying the index again.
1033        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        // Framed: one header line per version, a blank line between entries, and
1160        // each content block terminated by a newline.
1161        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")); // blank line separates entries
1175
1176        // Jsonl: one JSON object per line carrying the version and content.
1177        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        // restore_target never overwrites the original: the first recovery file is
1203        // "<stem>.recovered.<ext>", so it persists content beside the original.
1204        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        // The next recovery file for the same base avoids the collision with a
1213        // numbered marker, and dropping it without persisting removes the reserved file.
1214        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        // Edit with replace_all=true must replace ALL occurrences, not just the first.
1458        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        // Default (replace_all=false) replaces only the first occurrence (Edit requires
1468        // old_string be unique; first == only).
1469        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        // A second Write overwrites; later edits apply on top of it, not the first.
1545        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        // Deltas alone cannot rebuild full content.
1556        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        // Nothing exists → first candidate.
1581        let first = restore_target(original, |_| false);
1582        assert_eq!(first, Path::new("/repo/src/db.recovered.rs"));
1583        // `.recovered.rs` taken → bump to `_2`.
1584        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        // `..` in a session-recorded path must be refused (could escape the tree).
1592        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        // Normal cases: absolute path to the user's own file, or a clean relative path.
1595        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        // Normal case: the basename of the session-recorded path.
1622        assert_eq!(
1623            safe_output_name(Path::new("/home/u/proj/src/main.rs"), "main.rs"),
1624            PathBuf::from("main.rs")
1625        );
1626        // Recorded path ends in `..` (file_name None) → fall back to the --file basename.
1627        assert_eq!(
1628            safe_output_name(Path::new("foo/.."), "evil.rs"),
1629            PathBuf::from("evil.rs")
1630        );
1631        // A traversal/absolute --file arg is reduced to its bare last component.
1632        assert_eq!(
1633            safe_output_name(Path::new(".."), "../../etc/passwd"),
1634            PathBuf::from("passwd")
1635        );
1636        // Neither source yields a name → literal fallback (still a single component).
1637        assert_eq!(
1638            safe_output_name(Path::new(".."), ".."),
1639            PathBuf::from("recovered")
1640        );
1641        // The joined result always stays inside the chosen output dir.
1642        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        // The O(n) one-pass counts must equal the O(n^2) per-version reconstruct, including
1658        // a re-Write mid-history and a delta that changes the line count.
1659        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        // Edit-only prefix has no full-content base → 0 until a Write appears.
1676        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}