parley-cli 0.1.0-rc4

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

use crate::domain::{config::AppConfig, review::ReviewSession};
use tokio::fs;

#[derive(Debug, thiserror::Error)]
pub enum StoreError {
    #[error("invalid review name: {0}")]
    InvalidReviewName(String),
    #[error("review not found: {0}")]
    ReviewNotFound(String),
    #[error("io error: {0}")]
    Io(#[from] std::io::Error),
    #[error("json error: {0}")]
    Json(#[from] serde_json::Error),
    #[error("toml deserialize error: {0}")]
    TomlDeserialize(#[from] toml::de::Error),
    #[error("toml serialize error: {0}")]
    TomlSerialize(#[from] toml::ser::Error),
}

pub type StoreResult<T> = Result<T, StoreError>;

#[derive(Debug, Clone)]
pub struct Store {
    root: PathBuf,
}

impl Store {
    pub fn from_project_root(project_root: impl AsRef<Path>) -> Self {
        Self {
            root: project_root.as_ref().join(".parley"),
        }
    }

    pub async fn ensure_dirs(&self) -> StoreResult<()> {
        fs::create_dir_all(self.reviews_dir()).await?;
        fs::create_dir_all(self.logs_dir()).await?;
        Ok(())
    }

    pub async fn create_review(&self, session: &ReviewSession) -> StoreResult<()> {
        validate_review_name(&session.name)?;
        self.save_review(session).await
    }

    pub async fn save_review(&self, session: &ReviewSession) -> StoreResult<()> {
        validate_review_name(&session.name)?;
        self.ensure_dirs().await?;

        let path = self.review_path(&session.name)?;
        let data = serde_json::to_vec_pretty(session)?;
        fs::write(path, data).await?;
        Ok(())
    }

    pub async fn load_review(&self, name: &str) -> StoreResult<ReviewSession> {
        validate_review_name(name)?;
        let path = self.review_path(name)?;

        match fs::read(&path).await {
            Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
                Err(StoreError::ReviewNotFound(name.to_string()))
            }
            Err(error) => Err(StoreError::Io(error)),
        }
    }

    pub async fn list_reviews(&self) -> StoreResult<Vec<String>> {
        self.ensure_dirs().await?;
        let mut dir = fs::read_dir(self.reviews_dir()).await?;
        let mut result = Vec::new();

        while let Some(entry) = dir.next_entry().await? {
            let path = entry.path();
            if path.extension().and_then(|value| value.to_str()) == Some("json")
                && let Some(stem) = path.file_stem().and_then(|value| value.to_str())
            {
                result.push(stem.to_string());
            }
        }

        result.sort_unstable();
        Ok(result)
    }

    pub async fn load_config(&self) -> StoreResult<AppConfig> {
        self.ensure_dirs().await?;
        let path = self.config_path();

        match fs::read(&path).await {
            Ok(bytes) => {
                let text = String::from_utf8(bytes).map_err(|error| {
                    StoreError::Io(std::io::Error::new(
                        std::io::ErrorKind::InvalidData,
                        format!("invalid utf-8 in config.toml: {error}"),
                    ))
                })?;
                Ok(toml::from_str(&text)?)
            }
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
                self.load_legacy_json_config().await
            }
            Err(error) => Err(StoreError::Io(error)),
        }
    }

    pub async fn save_config(&self, config: &AppConfig) -> StoreResult<()> {
        self.ensure_dirs().await?;
        let data = toml::to_string_pretty(config)?;
        fs::write(self.config_path(), data).await?;
        Ok(())
    }

    fn review_path(&self, name: &str) -> StoreResult<PathBuf> {
        validate_review_name(name)?;
        Ok(self.reviews_dir().join(format!("{name}.json")))
    }

    fn reviews_dir(&self) -> PathBuf {
        self.root.join("reviews")
    }

    fn logs_dir(&self) -> PathBuf {
        self.root.join("logs")
    }

    pub fn review_log_path(&self, review_name: &str) -> StoreResult<PathBuf> {
        validate_review_name(review_name)?;
        Ok(self.logs_dir().join(format!("{review_name}.log")))
    }

    fn config_path(&self) -> PathBuf {
        self.root.join("config.toml")
    }

    fn legacy_config_path(&self) -> PathBuf {
        self.root.join("config.json")
    }

    async fn load_legacy_json_config(&self) -> StoreResult<AppConfig> {
        match fs::read(self.legacy_config_path()).await {
            Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(AppConfig::default()),
            Err(error) => Err(StoreError::Io(error)),
        }
    }
}

pub fn validate_review_name(name: &str) -> StoreResult<()> {
    if name.is_empty() {
        return Err(StoreError::InvalidReviewName(name.to_string()));
    }

    if name
        .chars()
        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
    {
        Ok(())
    } else {
        Err(StoreError::InvalidReviewName(name.to_string()))
    }
}

#[cfg(test)]
mod tests {
    use crate::domain::config::{AiConfig, DiffViewMode};
    use tempfile::tempdir;

    #[tokio::test]
    async fn save_and_load_review_should_round_trip() {
        let tmp = tempdir().expect("tempdir should exist");
        let store = super::Store::from_project_root(tmp.path());
        let review = super::ReviewSession::new("r1".into(), 1);

        store
            .save_review(&review)
            .await
            .expect("review should save successfully");
        let loaded = store
            .load_review("r1")
            .await
            .expect("review should load successfully");

        assert_eq!(loaded.name, "r1");
        assert_eq!(loaded.state, review.state);
    }

    #[test]
    fn validate_review_name_should_reject_slash() {
        let result = super::validate_review_name("bad/name");

        assert!(result.is_err());
    }

    #[tokio::test]
    async fn save_and_load_config_should_round_trip() {
        let tmp = tempdir().expect("tempdir should exist");
        let store = super::Store::from_project_root(tmp.path());
        let config = super::AppConfig {
            user_name: "User".to_string(),
            theme: "nord".to_string(),
            diff_view: DiffViewMode::Unified,
            log_level: "debug".to_string(),
            ai: AiConfig::default(),
        };

        store
            .save_config(&config)
            .await
            .expect("config should save successfully");
        let loaded = store
            .load_config()
            .await
            .expect("config should load successfully");

        assert_eq!(loaded, config);
    }

    #[tokio::test]
    async fn load_config_should_support_legacy_name_field() {
        let tmp = tempdir().expect("tempdir should exist");
        let store = super::Store::from_project_root(tmp.path());
        store
            .ensure_dirs()
            .await
            .expect("store dirs should be created");

        super::fs::write(
            tmp.path().join(".parley").join("config.toml"),
            "name = \"User\"\ntheme = \"nord\"\n",
        )
        .await
        .expect("legacy config should be written");

        let loaded = store
            .load_config()
            .await
            .expect("legacy config should load successfully");

        assert_eq!(loaded.user_name, "User");
        assert_eq!(loaded.theme, "nord");
        assert_eq!(loaded.diff_view, DiffViewMode::SideBySide);
        assert_eq!(loaded.log_level, "info");
    }
}