parley-cli 0.1.0-rc4

Terminal-first review tool for AI-generated code changes
Documentation
use std::{io, path::Path, process::Command, sync::OnceLock};

use anyhow::{Context, Result};
use crossterm::{
    event::{DisableMouseCapture, EnableMouseCapture},
    execute,
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend, layout::Rect};
use time::{OffsetDateTime, UtcOffset};

use crate::domain::{
    diff::DiffLineKind,
    review::{DiffSide, LineComment},
};

use super::DisplayRow;

pub(super) const MOUSE_WHEEL_SCROLL_LINES: usize = 3;
pub(super) const MOUSE_WHEEL_FILE_SCROLL_FILES: usize = 3;

pub(super) fn comment_matches_display_row(comment: &LineComment, row: &DisplayRow) -> bool {
    if !matches!(
        row.kind,
        DiffLineKind::Added | DiffLineKind::Removed | DiffLineKind::Context
    ) {
        return false;
    }

    match (comment.old_line, comment.new_line) {
        (Some(old), Some(new)) => {
            // Prefer exact anchor pairs; if the right-side line drifts after edits,
            // keep the thread attached by the stable old-side line mapping.
            (row.old_line == Some(old) && row.new_line == Some(new)) || row.old_line == Some(old)
        }
        (Some(old), None) => {
            if matches!(comment.side, DiffSide::Right) {
                false
            } else {
                row.old_line == Some(old)
            }
        }
        (None, Some(new)) => {
            if matches!(comment.side, DiffSide::Left) {
                false
            } else {
                row.new_line == Some(new)
            }
        }
        (None, None) => false,
    }
}

pub(super) fn format_line_reference(old_line: Option<u32>, new_line: Option<u32>) -> String {
    match (old_line, new_line) {
        (Some(old), Some(new)) => format!("{old}:{new}"),
        (Some(old), None) => format!("{old}:_"),
        (None, Some(new)) => format!("_:{new}"),
        (None, None) => "_:_".to_string(),
    }
}

pub(super) fn format_timestamp_utc(timestamp_ms: u64) -> String {
    let nanos_since_epoch = (timestamp_ms as i128).saturating_mul(1_000_000);
    let utc_dt = OffsetDateTime::from_unix_timestamp_nanos(nanos_since_epoch)
        .expect("timestamp ms should be representable as UTC date-time");
    let local_dt = local_utc_offset()
        .map(|offset| utc_dt.to_offset(offset))
        .unwrap_or(utc_dt);
    let month: u8 = local_dt.month().into();
    let offset_seconds = local_dt.offset().whole_seconds();
    let sign = if offset_seconds < 0 { '-' } else { '+' };
    let abs_offset_seconds = offset_seconds.abs();
    let offset_hours = abs_offset_seconds / 3600;
    let offset_minutes = (abs_offset_seconds % 3600) / 60;
    format!(
        "{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:03} UTC{}{offset_hours:02}:{offset_minutes:02}",
        local_dt.year(),
        month,
        local_dt.day(),
        local_dt.hour(),
        local_dt.minute(),
        local_dt.second(),
        local_dt.millisecond(),
        sign
    )
}

fn local_utc_offset() -> Option<UtcOffset> {
    static OFFSET_SECONDS: OnceLock<Option<i32>> = OnceLock::new();
    let seconds = OFFSET_SECONDS
        .get_or_init(|| {
            let output = Command::new("date").arg("+%z").output().ok()?;
            if !output.status.success() {
                return None;
            }
            let raw = String::from_utf8(output.stdout).ok()?;
            parse_utc_offset_seconds(raw.trim())
        })
        .to_owned()?;
    UtcOffset::from_whole_seconds(seconds).ok()
}

fn parse_utc_offset_seconds(raw: &str) -> Option<i32> {
    if raw.len() != 5 {
        return None;
    }
    let sign = match raw.as_bytes()[0] {
        b'+' => 1,
        b'-' => -1,
        _ => return None,
    };
    let hours: i32 = raw[1..3].parse().ok()?;
    let minutes: i32 = raw[3..5].parse().ok()?;
    Some(sign * (hours * 3600 + minutes * 60))
}

pub(super) fn slice_chars(input: &str, start: usize, len: usize) -> String {
    if len == 0 {
        return String::new();
    }
    input.chars().skip(start).take(len).collect()
}

pub(super) fn point_in_rect(x: u16, y: u16, rect: Rect) -> bool {
    x >= rect.x
        && x < rect.x.saturating_add(rect.width)
        && y >= rect.y
        && y < rect.y.saturating_add(rect.height)
}

pub(super) fn open_log_in_less(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    log_path: &Path,
    mouse_capture_enabled: bool,
) -> Result<()> {
    if let Some(parent) = log_path.parent() {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("failed to create log directory {}", parent.display()))?;
    }
    let _ = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(log_path)
        .with_context(|| format!("failed to create/open log file {}", log_path.display()))?;

    disable_raw_mode().context("failed to disable raw mode before launching less")?;
    if mouse_capture_enabled {
        execute!(
            terminal.backend_mut(),
            LeaveAlternateScreen,
            DisableMouseCapture
        )
        .context("failed to leave alternate screen before launching less")?;
    } else {
        execute!(terminal.backend_mut(), LeaveAlternateScreen)
            .context("failed to leave alternate screen before launching less")?;
    }
    terminal.show_cursor().context("failed to show cursor")?;

    let less_result = Command::new("less")
        .arg("+G")
        .arg(log_path)
        .status()
        .with_context(|| format!("failed to launch less for {}", log_path.display()));

    if mouse_capture_enabled {
        execute!(
            terminal.backend_mut(),
            EnterAlternateScreen,
            EnableMouseCapture
        )
        .context("failed to re-enter alternate screen after less")?;
    } else {
        execute!(terminal.backend_mut(), EnterAlternateScreen)
            .context("failed to re-enter alternate screen after less")?;
    }
    enable_raw_mode().context("failed to re-enable raw mode after less")?;
    terminal
        .hide_cursor()
        .context("failed to hide cursor after less")?;
    terminal
        .clear()
        .context("failed to clear terminal after less")?;

    let status = less_result?;
    if !status.success() {
        return Err(anyhow::anyhow!("less exited with status {status}"));
    }
    Ok(())
}

pub(super) fn insert_char_at(text: &mut String, char_index: usize, ch: char) {
    let mut chars: Vec<char> = text.chars().collect();
    let idx = char_index.min(chars.len());
    chars.insert(idx, ch);
    *text = chars.into_iter().collect();
}

pub(super) fn remove_char_at(text: &mut String, char_index: usize) {
    let mut chars: Vec<char> = text.chars().collect();
    if char_index < chars.len() {
        chars.remove(char_index);
        *text = chars.into_iter().collect();
    }
}

#[cfg(test)]
mod tests {
    use super::{DisplayRow, comment_matches_display_row, parse_utc_offset_seconds};
    use crate::domain::{
        diff::DiffLineKind,
        review::{Author, CommentStatus, DiffSide, LineComment},
    };

    fn make_row(kind: DiffLineKind, old_line: Option<u32>, new_line: Option<u32>) -> DisplayRow {
        DisplayRow {
            kind,
            old_line,
            new_line,
            raw: String::new(),
            code: String::new(),
        }
    }

    fn make_comment(side: DiffSide, old_line: Option<u32>, new_line: Option<u32>) -> LineComment {
        LineComment {
            id: 1,
            file_path: "src/lib.rs".to_string(),
            old_line,
            new_line,
            side,
            body: "x".to_string(),
            author: Author::User,
            status: CommentStatus::Open,
            replies: Vec::new(),
            created_at_ms: 0,
            updated_at_ms: 0,
            addressed_at_ms: None,
        }
    }

    #[test]
    fn parses_positive_utc_offset() {
        assert_eq!(parse_utc_offset_seconds("+0200"), Some(2 * 3600));
        assert_eq!(parse_utc_offset_seconds("+0530"), Some(5 * 3600 + 30 * 60));
    }

    #[test]
    fn parses_negative_utc_offset() {
        assert_eq!(parse_utc_offset_seconds("-0700"), Some(-7 * 3600));
        assert_eq!(
            parse_utc_offset_seconds("-0330"),
            Some(-(3 * 3600 + 30 * 60))
        );
    }

    #[test]
    fn rejects_invalid_utc_offset() {
        assert_eq!(parse_utc_offset_seconds(""), None);
        assert_eq!(parse_utc_offset_seconds("0200"), None);
        assert_eq!(parse_utc_offset_seconds("+2"), None);
        assert_eq!(parse_utc_offset_seconds("+25AA"), None);
    }

    #[test]
    fn anchor_with_both_lines_prefers_exact_pair() {
        let comment = make_comment(DiffSide::Right, Some(8), Some(7));
        let exact = make_row(DiffLineKind::Context, Some(8), Some(7));
        assert!(comment_matches_display_row(&comment, &exact));
    }

    #[test]
    fn anchor_with_both_lines_falls_back_to_old_line_on_shift() {
        let comment = make_comment(DiffSide::Right, Some(8), Some(7));
        let shifted = make_row(DiffLineKind::Context, Some(8), Some(10));
        assert!(comment_matches_display_row(&comment, &shifted));
    }

    #[test]
    fn anchor_with_both_lines_does_not_match_new_line_only() {
        let comment = make_comment(DiffSide::Right, Some(8), Some(7));
        let wrong_context = make_row(DiffLineKind::Context, Some(5), Some(7));
        assert!(!comment_matches_display_row(&comment, &wrong_context));
    }
}