parley-cli 0.1.0-rc4

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, NewLineComment, 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 body: String,
    pub author: Author,
}

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

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 load_or_create_review(&self, name: &str) -> Result<ReviewSession> {
        match self.store.load_review(name).await {
            Ok(review) => Ok(review),
            Err(_) => self.create_review(name).await,
        }
    }

    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,
                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)
    }

    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)
}