use crate::error::{Error, Result};
use crate::storage::StorageBackend;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::fs;
pub const DEFAULT_PREFIX: &str = "proj";
pub const RIVETS_DIR_NAME: &str = ".rivets";
pub const CONFIG_FILE_NAME: &str = "config.yaml";
pub const ISSUES_FILE_NAME: &str = "issues.jsonl";
pub const GITIGNORE_FILE_NAME: &str = ".gitignore";
pub const MIN_PREFIX_LENGTH: usize = 2;
pub const MAX_PREFIX_LENGTH: usize = 20;
pub const MAX_TRAVERSAL_DEPTH: usize = 256;
pub const DEFAULT_BACKEND: &str = "jsonl";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RivetsConfig {
#[serde(rename = "issue-prefix")]
pub issue_prefix: String,
pub storage: StorageConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StorageConfig {
pub backend: String,
pub data_file: String,
}
impl StorageConfig {
pub fn to_backend(&self, root_dir: impl AsRef<Path>) -> Result<StorageBackend> {
let root_dir = root_dir.as_ref();
let data_file_path = Path::new(&self.data_file);
if data_file_path.is_absolute() {
return Err(Error::Config(
"data_file must be a relative path".to_string(),
));
}
let data_path = root_dir.join(&self.data_file);
match self.backend.as_str() {
"jsonl" => Ok(StorageBackend::Jsonl(data_path)),
"postgresql" => Err(Error::Config(
"PostgreSQL backend is not yet implemented. \
See https://github.com/dwalleck/rivets/issues for tracking."
.to_string(),
)),
other => Err(Error::Config(format!(
"Unknown storage backend '{other}'. Supported backends: jsonl, postgresql"
))),
}
}
}
impl RivetsConfig {
pub fn new(prefix: &str) -> Self {
Self {
issue_prefix: prefix.to_string(),
storage: StorageConfig {
backend: DEFAULT_BACKEND.to_string(),
data_file: format!("{}/{}", RIVETS_DIR_NAME, ISSUES_FILE_NAME),
},
}
}
pub async fn load(path: &Path) -> Result<Self> {
let content = fs::read_to_string(path).await?;
let config: Self = serde_yaml::from_str(&content).map_err(|e| {
Error::Config(format!(
"Failed to parse config file '{}': {}",
path.display(),
e
))
})?;
validate_prefix(&config.issue_prefix)?;
Ok(config)
}
pub async fn save(&self, path: &Path) -> Result<()> {
let content =
serde_yaml::to_string(self).map_err(|e| Error::Config(format!("YAML error: {}", e)))?;
fs::write(path, content).await?;
Ok(())
}
}
impl Default for RivetsConfig {
fn default() -> Self {
Self::new(DEFAULT_PREFIX)
}
}
#[derive(Debug)]
pub struct InitResult {
pub rivets_dir: PathBuf,
pub config_file: PathBuf,
pub issues_file: PathBuf,
pub gitignore_file: PathBuf,
pub prefix: String,
}
pub fn validate_prefix(prefix: &str) -> Result<()> {
if prefix.len() < MIN_PREFIX_LENGTH {
return Err(Error::Config(format!(
"Prefix must be at least {} characters",
MIN_PREFIX_LENGTH
)));
}
if prefix.len() > MAX_PREFIX_LENGTH {
return Err(Error::Config(format!(
"Prefix cannot exceed {} characters",
MAX_PREFIX_LENGTH
)));
}
if !prefix.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(Error::Config(
"Prefix must contain only alphanumeric characters".to_string(),
));
}
Ok(())
}
pub async fn init(base_dir: &Path, prefix: Option<&str>) -> Result<InitResult> {
let prefix = prefix.unwrap_or(DEFAULT_PREFIX).trim();
validate_prefix(prefix)?;
let rivets_dir = base_dir.join(RIVETS_DIR_NAME);
match fs::create_dir(&rivets_dir).await {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(Error::Config(format!(
"Rivets is already initialized in this directory. Found existing '{}'",
RIVETS_DIR_NAME
)));
}
Err(e) => return Err(e.into()),
}
let config_file = rivets_dir.join(CONFIG_FILE_NAME);
let config = RivetsConfig::new(prefix);
config.save(&config_file).await?;
let issues_file = rivets_dir.join(ISSUES_FILE_NAME);
fs::write(&issues_file, &[] as &[u8]).await?;
let gitignore_file = rivets_dir.join(GITIGNORE_FILE_NAME);
let gitignore_content = "\
# Rivets metadata files that should not be tracked
# The issues.jsonl file should be tracked for collaboration
";
fs::write(&gitignore_file, gitignore_content).await?;
Ok(InitResult {
rivets_dir,
config_file,
issues_file,
gitignore_file,
prefix: prefix.to_string(),
})
}
pub fn is_initialized(base_dir: &Path) -> bool {
base_dir.join(RIVETS_DIR_NAME).exists()
}
pub fn find_rivets_root(start_dir: &Path) -> Option<PathBuf> {
let mut current = start_dir.to_path_buf();
let mut depth = 0;
loop {
if current.join(RIVETS_DIR_NAME).exists() {
return Some(current);
}
depth += 1;
if depth > MAX_TRAVERSAL_DEPTH || !current.pop() {
return None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use tempfile::TempDir;
#[rstest]
#[case::valid_short("ab")]
#[case::valid_medium("proj")]
#[case::valid_long("rivets")]
#[case::valid_alphanumeric("test123")]
#[case::valid_uppercase("PROJ")]
#[case::valid_mixed_case("ProjTest123")]
#[case::valid_max_length("a1b2c3d4e5f6g7h8i9j0")]
fn test_validate_prefix_valid(#[case] prefix: &str) {
assert!(validate_prefix(prefix).is_ok());
}
#[rstest]
#[case::too_short_single("a", "at least 2")]
#[case::too_short_empty("", "at least 2")]
#[case::too_long("a".repeat(21), "cannot exceed 20")]
#[case::hyphen("proj-test", "alphanumeric")]
#[case::underscore("proj_test", "alphanumeric")]
#[case::space("proj test", "alphanumeric")]
#[case::dot("proj.test", "alphanumeric")]
fn test_validate_prefix_invalid(#[case] prefix: impl AsRef<str>, #[case] expected_error: &str) {
let result = validate_prefix(prefix.as_ref());
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string().to_lowercase();
assert!(
err_msg.contains(&expected_error.to_lowercase()),
"Expected error to contain '{}', got: '{}'",
expected_error,
err_msg
);
}
#[test]
fn test_validate_prefix_rejects_whitespace() {
let result = validate_prefix(" ab ");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.to_lowercase()
.contains("alphanumeric"));
}
#[test]
fn test_config_new() {
let config = RivetsConfig::new("myproj");
assert_eq!(config.issue_prefix, "myproj");
assert_eq!(config.storage.backend, "jsonl");
assert_eq!(config.storage.data_file, ".rivets/issues.jsonl");
}
#[test]
fn test_config_default() {
let config = RivetsConfig::default();
assert_eq!(config.issue_prefix, DEFAULT_PREFIX);
}
#[tokio::test]
async fn test_config_save_and_load() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.yaml");
let original = RivetsConfig::new("test123");
original.save(&config_path).await.unwrap();
let loaded = RivetsConfig::load(&config_path).await.unwrap();
assert_eq!(original, loaded);
}
#[tokio::test]
async fn test_config_yaml_format() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.yaml");
let config = RivetsConfig::new("myproj");
config.save(&config_path).await.unwrap();
let content = tokio::fs::read_to_string(&config_path).await.unwrap();
assert!(content.contains("issue-prefix: myproj"));
assert!(content.contains("backend: jsonl"));
assert!(content.contains("data_file: .rivets/issues.jsonl"));
}
#[tokio::test]
async fn test_config_load_validates_prefix() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.yaml");
let invalid_config = r#"issue-prefix: x
storage:
backend: jsonl
data_file: .rivets/issues.jsonl
"#;
tokio::fs::write(&config_path, invalid_config)
.await
.unwrap();
let result = RivetsConfig::load(&config_path).await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("at least"));
}
#[test]
fn test_to_backend_jsonl_success() {
let temp_dir = TempDir::new().unwrap();
let config = StorageConfig {
backend: "jsonl".to_string(),
data_file: "data/issues.jsonl".to_string(),
};
let result = config.to_backend(temp_dir.path());
assert!(result.is_ok());
let backend = result.unwrap();
assert!(matches!(backend, StorageBackend::Jsonl(_)));
assert_eq!(
backend.data_path().unwrap(),
temp_dir.path().join("data/issues.jsonl")
);
}
#[test]
fn test_to_backend_unknown_backend_error() {
let temp_dir = TempDir::new().unwrap();
let config = StorageConfig {
backend: "unknown".to_string(),
data_file: "issues.jsonl".to_string(),
};
let result = config.to_backend(temp_dir.path());
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Unknown storage backend"));
assert!(err_msg.contains("unknown"));
}
#[test]
fn test_to_backend_postgresql_not_implemented() {
let temp_dir = TempDir::new().unwrap();
let config = StorageConfig {
backend: "postgresql".to_string(),
data_file: "".to_string(),
};
let result = config.to_backend(temp_dir.path());
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("not yet implemented"));
}
#[test]
fn test_to_backend_absolute_path_rejected() {
let temp_dir = TempDir::new().unwrap();
#[cfg(windows)]
let absolute_path = "C:\\absolute\\path\\issues.jsonl";
#[cfg(not(windows))]
let absolute_path = "/absolute/path/issues.jsonl";
let config = StorageConfig {
backend: "jsonl".to_string(),
data_file: absolute_path.to_string(),
};
let result = config.to_backend(temp_dir.path());
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("relative path"));
}
#[tokio::test]
async fn test_init_creates_directory_structure() {
let temp_dir = TempDir::new().unwrap();
let result = init(temp_dir.path(), None).await.unwrap();
assert!(result.rivets_dir.exists());
assert!(result.config_file.exists());
assert!(result.issues_file.exists());
assert!(result.gitignore_file.exists());
}
#[tokio::test]
async fn test_init_with_custom_prefix() {
let temp_dir = TempDir::new().unwrap();
let result = init(temp_dir.path(), Some("myproj")).await.unwrap();
assert_eq!(result.prefix, "myproj");
let config = RivetsConfig::load(&result.config_file).await.unwrap();
assert_eq!(config.issue_prefix, "myproj");
}
#[tokio::test]
async fn test_init_trims_prefix_whitespace() {
let temp_dir = TempDir::new().unwrap();
let result = init(temp_dir.path(), Some(" myproj ")).await.unwrap();
assert_eq!(result.prefix, "myproj");
let config = RivetsConfig::load(&result.config_file).await.unwrap();
assert_eq!(config.issue_prefix, "myproj");
}
#[tokio::test]
async fn test_init_with_default_prefix() {
let temp_dir = TempDir::new().unwrap();
let result = init(temp_dir.path(), None).await.unwrap();
assert_eq!(result.prefix, DEFAULT_PREFIX);
}
#[tokio::test]
async fn test_init_fails_if_already_initialized() {
let temp_dir = TempDir::new().unwrap();
init(temp_dir.path(), None).await.unwrap();
let result = init(temp_dir.path(), None).await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string().to_lowercase();
assert!(err_msg.contains("already initialized"));
}
#[tokio::test]
async fn test_init_fails_with_invalid_prefix() {
let temp_dir = TempDir::new().unwrap();
let result = init(temp_dir.path(), Some("a")).await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string().to_lowercase();
assert!(err_msg.contains("at least 2"));
}
#[tokio::test]
async fn test_init_creates_empty_issues_file() {
let temp_dir = TempDir::new().unwrap();
let result = init(temp_dir.path(), None).await.unwrap();
let content = tokio::fs::read_to_string(&result.issues_file)
.await
.unwrap();
assert!(content.is_empty());
}
#[tokio::test]
async fn test_init_creates_gitignore() {
let temp_dir = TempDir::new().unwrap();
let result = init(temp_dir.path(), None).await.unwrap();
let content = tokio::fs::read_to_string(&result.gitignore_file)
.await
.unwrap();
assert!(content.contains("Rivets"));
}
#[test]
fn test_is_initialized_true() {
let temp_dir = TempDir::new().unwrap();
std::fs::create_dir(temp_dir.path().join(RIVETS_DIR_NAME)).unwrap();
assert!(is_initialized(temp_dir.path()));
}
#[test]
fn test_is_initialized_false() {
let temp_dir = TempDir::new().unwrap();
assert!(!is_initialized(temp_dir.path()));
}
#[test]
fn test_find_rivets_root_in_current_dir() {
let temp_dir = TempDir::new().unwrap();
std::fs::create_dir(temp_dir.path().join(RIVETS_DIR_NAME)).unwrap();
let found = find_rivets_root(temp_dir.path());
assert_eq!(found, Some(temp_dir.path().to_path_buf()));
}
#[test]
fn test_find_rivets_root_in_parent_dir() {
let temp_dir = TempDir::new().unwrap();
std::fs::create_dir(temp_dir.path().join(RIVETS_DIR_NAME)).unwrap();
let sub_dir = temp_dir.path().join("sub").join("nested");
std::fs::create_dir_all(&sub_dir).unwrap();
let found = find_rivets_root(&sub_dir);
assert_eq!(found, Some(temp_dir.path().to_path_buf()));
}
#[test]
fn test_find_rivets_root_not_found() {
let temp_dir = TempDir::new().unwrap();
let found = find_rivets_root(temp_dir.path());
assert!(found.is_none());
}
}