travelagent-core 1.10.3

Core library for travelagent code review tool
Documentation
use std::path::{Path, PathBuf};

use crate::error::{Result, TrvError};
use crate::model::{DiffFile, DiffHunk, DiffLine, FileStatus, LineOrigin};

use super::traits::{VcsBackend, VcsInfo, VcsType};

/// A backend for reviewing a single file without a VCS repository.
///
/// All lines are presented as additions (like a new-file diff), allowing the
/// user to annotate any file without needing git, hg, or jj.
pub struct FileBackend {
    info: VcsInfo,
    /// Absolute path to the file being reviewed
    file_path: PathBuf,
}

impl FileBackend {
    /// Create a new `FileBackend` for the given file path.
    ///
    /// The path is resolved to an absolute path. The file must exist and be
    /// readable.
    pub fn new(path: &str) -> Result<Self> {
        let file_path = std::fs::canonicalize(path).map_err(|e| {
            TrvError::Io(std::io::Error::new(
                e.kind(),
                format!("Cannot open file '{path}': {e}"),
            ))
        })?;

        if !file_path.is_file() {
            return Err(TrvError::Io(std::io::Error::new(
                std::io::ErrorKind::InvalidInput,
                format!("'{path}' is not a file"),
            )));
        }

        // Use the full file path as root_path so that each file gets a unique
        // session fingerprint. Previously this used the parent directory, which
        // caused all files in the same directory to share one session.
        let root_path = file_path.clone();

        let info = VcsInfo {
            root_path,
            head_commit: "file".to_string(),
            branch_name: None,
            vcs_type: VcsType::File,
        };

        Ok(Self { info, file_path })
    }
}

impl VcsBackend for FileBackend {
    fn info(&self) -> &VcsInfo {
        &self.info
    }

    fn get_working_tree_diff(&self) -> Result<Vec<DiffFile>> {
        let content = std::fs::read_to_string(&self.file_path)?;
        let lines: Vec<&str> = content.lines().collect();

        // Relative path from root (just the filename)
        let rel_path = self
            .file_path
            .file_name()
            .map_or_else(|| self.file_path.clone(), PathBuf::from);

        if lines.is_empty() {
            // Return a DiffFile with zero hunks so the user can still
            // add file-level comments on empty files.
            let file = DiffFile {
                old_path: None,
                new_path: Some(rel_path),
                status: FileStatus::Added,
                hunks: vec![],
                is_binary: false,
                is_too_large: false,
                is_commit_message: false,
            };
            return Ok(vec![file]);
        }

        // Tabs must still be normalized here so rendering matches the other
        // backends. Syntax decoration (if any) is applied post-fetch.
        let line_contents: Vec<String> = lines.iter().map(|l| l.replace('\t', "    ")).collect();

        // Build DiffLines
        let mut diff_lines = Vec::with_capacity(lines.len());
        for (i, content) in line_contents.iter().enumerate() {
            let line_num = (i + 1) as u32;

            diff_lines.push(DiffLine {
                origin: LineOrigin::Addition,
                content: content.clone(),
                old_lineno: None,
                new_lineno: Some(line_num),
                highlighted_spans: None,
            });
        }

        let total_lines = lines.len() as u32;

        let hunk = DiffHunk {
            header: format!("@@ -0,0 +1,{total_lines} @@"),
            lines: diff_lines,
            old_start: 0,
            old_count: 0,
            new_start: 1,
            new_count: total_lines,
        };

        let file = DiffFile {
            old_path: None,
            new_path: Some(rel_path),
            status: FileStatus::Added,
            hunks: vec![hunk],
            is_binary: false,
            is_too_large: false,
            is_commit_message: false,
        };

        Ok(vec![file])
    }

    fn fetch_context_lines(
        &self,
        _file_path: &Path,
        _file_status: FileStatus,
        start_line: u32,
        end_line: u32,
    ) -> Result<Vec<DiffLine>> {
        if start_line > end_line || start_line == 0 {
            return Ok(Vec::new());
        }

        let content = std::fs::read_to_string(&self.file_path)?;
        let lines: Vec<&str> = content.lines().collect();
        let mut result = Vec::new();

        for line_num in start_line..=end_line {
            let idx = (line_num - 1) as usize;
            if idx < lines.len() {
                result.push(DiffLine {
                    origin: LineOrigin::Context,
                    content: lines[idx].to_string(),
                    old_lineno: Some(line_num),
                    new_lineno: Some(line_num),
                    highlighted_spans: None,
                });
            }
        }

        Ok(result)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_file_returns_ok_with_zero_hunks() {
        let dir = tempfile::tempdir().expect("failed to create temp dir");
        let file_path = dir.path().join("empty.rs");
        std::fs::write(&file_path, "").expect("failed to write empty file");

        let backend =
            FileBackend::new(file_path.to_str().unwrap()).expect("failed to create backend");
        let result = backend.get_working_tree_diff();

        assert!(result.is_ok(), "empty file should not be an error");
        let files = result.unwrap();
        assert_eq!(files.len(), 1);
        assert!(
            files[0].hunks.is_empty(),
            "empty file should have zero hunks"
        );
        assert_eq!(files[0].status, FileStatus::Added);
    }

    #[test]
    fn files_in_same_directory_have_different_session_keys() {
        let dir = tempfile::tempdir().expect("failed to create temp dir");
        let file_a = dir.path().join("a.rs");
        let file_b = dir.path().join("b.rs");
        std::fs::write(&file_a, "a").expect("write a");
        std::fs::write(&file_b, "b").expect("write b");

        let backend_a =
            FileBackend::new(file_a.to_str().unwrap()).expect("failed to create backend a");
        let backend_b =
            FileBackend::new(file_b.to_str().unwrap()).expect("failed to create backend b");

        // root_path is the session fingerprint key; it must differ per file
        assert_ne!(
            backend_a.info().root_path,
            backend_b.info().root_path,
            "files in the same directory must have different root_path for unique session keys"
        );
    }

    #[test]
    fn non_empty_file_returns_hunks() {
        let dir = tempfile::tempdir().expect("failed to create temp dir");
        let file_path = dir.path().join("hello.rs");
        std::fs::write(&file_path, "fn main() {}\n").expect("failed to write file");

        let backend =
            FileBackend::new(file_path.to_str().unwrap()).expect("failed to create backend");
        let result = backend.get_working_tree_diff();

        assert!(result.is_ok());
        let files = result.unwrap();
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].hunks.len(), 1);
        assert!(!files[0].hunks[0].lines.is_empty());
    }
}