parley-cli 0.1.0

Terminal-first review tool for AI-generated code changes
Documentation
use std::{
    path::PathBuf,
    time::{SystemTime, UNIX_EPOCH},
};

use anyhow::{Context, Result, anyhow};

use crate::{
    domain::{
        config::AppConfig,
        review::{
            Author, CommentStatus, DiffSide, LineAnchorSnapshot, NewLineComment,
            ReanchorLineComment, ReviewSession, ReviewState,
        },
    },
    persistence::store::Store,
};

#[derive(Debug, Clone)]
pub struct ReviewService {
    store: Store,
}

#[derive(Debug, Clone)]
pub struct AddCommentInput {
    pub file_path: String,
    pub old_line: Option<u32>,
    pub new_line: Option<u32>,
    pub side: DiffSide,
    pub line_anchor: Option<LineAnchorSnapshot>,
    pub body: String,
    pub author: Author,
}

#[derive(Debug, Clone)]
pub struct AddReplyInput {
    pub comment_id: u64,
    pub author: Author,
    pub body: String,
}

#[derive(Debug, Clone)]
pub struct ReanchorCommentInput {
    pub comment_id: u64,
    pub file_path: String,
    pub old_line: Option<u32>,
    pub new_line: Option<u32>,
    pub side: DiffSide,
    pub line_anchor: Option<LineAnchorSnapshot>,
}

impl ReviewService {
    pub fn new(store: Store) -> Self {
        Self { store }
    }

    pub async fn create_review(&self, name: &str) -> Result<ReviewSession> {
        let session = ReviewSession::new(name.to_string(), now_ms()?);
        self.store
            .create_review(&session)
            .await
            .with_context(|| format!("failed to create review {name}"))?;
        Ok(session)
    }

    pub async fn load_review(&self, name: &str) -> Result<ReviewSession> {
        self.store
            .load_review(name)
            .await
            .with_context(|| format!("failed to load review {name}"))
    }

    pub async fn list_reviews(&self) -> Result<Vec<String>> {
        self.store
            .list_reviews()
            .await
            .context("failed to list reviews")
    }

    pub async fn load_config(&self) -> Result<AppConfig> {
        self.store
            .load_config()
            .await
            .context("failed to load parler config")
    }

    pub async fn save_config(&self, config: &AppConfig) -> Result<()> {
        self.store
            .save_config(config)
            .await
            .context("failed to save parler config")
    }

    pub fn review_log_path(&self, review_name: &str) -> Result<PathBuf> {
        self.store
            .review_log_path(review_name)
            .with_context(|| format!("failed to resolve log path for review {review_name}"))
    }

    pub async fn set_state(&self, name: &str, next: ReviewState) -> Result<ReviewSession> {
        let mut session = self.load_review(name).await?;
        session
            .set_state(next, now_ms()?)
            .map_err(|error| anyhow!(error))?;
        self.store
            .save_review(&session)
            .await
            .context("failed to save state change")?;
        Ok(session)
    }

    pub async fn set_state_force(&self, name: &str, next: ReviewState) -> Result<ReviewSession> {
        let mut session = self.load_review(name).await?;
        session
            .set_state_force(next, now_ms()?)
            .map_err(|error| anyhow!(error))?;
        self.store
            .save_review(&session)
            .await
            .context("failed to save forced state change")?;
        Ok(session)
    }

    pub async fn add_comment(&self, name: &str, input: AddCommentInput) -> Result<ReviewSession> {
        let mut session = self.load_review(name).await?;
        session.add_comment(
            NewLineComment {
                file_path: input.file_path,
                old_line: input.old_line,
                new_line: input.new_line,
                side: input.side,
                line_anchor: input.line_anchor,
                body: input.body,
                author: input.author,
            },
            now_ms()?,
        );
        self.store
            .save_review(&session)
            .await
            .context("failed to persist new comment")?;
        Ok(session)
    }

    pub async fn add_reply(&self, name: &str, input: AddReplyInput) -> Result<ReviewSession> {
        let mut session = self.load_review(name).await?;
        session
            .add_reply(input.comment_id, input.author, input.body, now_ms()?)
            .map_err(|error| anyhow!(error))?;
        self.store
            .save_review(&session)
            .await
            .context("failed to persist new reply")?;
        Ok(session)
    }

    pub async fn mark_addressed(
        &self,
        name: &str,
        comment_id: u64,
        actor: Author,
    ) -> Result<ReviewSession> {
        self.set_comment_status(name, comment_id, CommentStatus::Addressed, actor)
            .await
    }

    pub async fn mark_open(
        &self,
        name: &str,
        comment_id: u64,
        actor: Author,
    ) -> Result<ReviewSession> {
        self.set_comment_status(name, comment_id, CommentStatus::Open, actor)
            .await
    }

    pub async fn force_mark_addressed(&self, name: &str, comment_id: u64) -> Result<ReviewSession> {
        let mut session = self.load_review(name).await?;
        session
            .set_comment_status_force(comment_id, CommentStatus::Addressed, now_ms()?)
            .map_err(|error| anyhow!(error))?;
        self.store
            .save_review(&session)
            .await
            .context("failed to persist forced comment status")?;
        Ok(session)
    }

    pub async fn reanchor_comment(
        &self,
        name: &str,
        input: ReanchorCommentInput,
    ) -> Result<ReviewSession> {
        let mut session = self.load_review(name).await?;
        session
            .reanchor_comment(
                input.comment_id,
                ReanchorLineComment {
                    file_path: input.file_path,
                    old_line: input.old_line,
                    new_line: input.new_line,
                    side: input.side,
                    line_anchor: input.line_anchor,
                },
                now_ms()?,
            )
            .map_err(|error| anyhow!(error))?;
        self.store
            .save_review(&session)
            .await
            .context("failed to persist comment re-anchor")?;
        Ok(session)
    }

    pub async fn save_review(&self, session: &ReviewSession) -> Result<()> {
        self.store
            .save_review(session)
            .await
            .context("failed to save review session")
    }

    async fn set_comment_status(
        &self,
        name: &str,
        comment_id: u64,
        status: CommentStatus,
        actor: Author,
    ) -> Result<ReviewSession> {
        let mut session = self.load_review(name).await?;
        session
            .set_comment_status(comment_id, status, actor, now_ms()?)
            .map_err(|error| anyhow!(error))?;
        self.store
            .save_review(&session)
            .await
            .context("failed to persist comment status")?;
        Ok(session)
    }
}

fn now_ms() -> Result<u64> {
    let elapsed = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .context("system clock is before unix epoch")?;
    Ok(elapsed.as_millis() as u64)
}