use anyhow::{Context, Result};
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct CliConfig {
pub project_root: PathBuf,
pub migrations_dir: PathBuf,
pub model_paths: Vec<PathBuf>,
pub database_url: Option<String>,
}
impl Default for CliConfig {
fn default() -> Self {
Self {
project_root: PathBuf::from("."),
migrations_dir: PathBuf::from("migrations"),
model_paths: vec![PathBuf::from("src")],
database_url: None,
}
}
}
impl CliConfig {
pub fn load() -> Result<Self> {
let project_root = find_project_root()?;
let mut config = Self {
project_root: project_root.clone(),
migrations_dir: PathBuf::from("migrations"),
..Self::default()
};
config.database_url = std::env::var("DATABASE_URL").ok();
let ormada_toml = project_root.join("ormada.toml");
if ormada_toml.exists() {
let content = std::fs::read_to_string(&ormada_toml)
.with_context(|| format!("Failed to read {}", ormada_toml.display()))?;
parse_ormada_config(&content, &mut config);
return Ok(config);
}
let cargo_toml = project_root.join("Cargo.toml");
if cargo_toml.exists() {
let content = std::fs::read_to_string(&cargo_toml)
.with_context(|| format!("Failed to read {}", cargo_toml.display()))?;
parse_cargo_metadata(&content, &mut config);
}
Ok(config)
}
pub fn migrations_path(&self) -> PathBuf {
if self.migrations_dir.is_absolute() {
self.migrations_dir.clone()
} else {
self.project_root.join(&self.migrations_dir)
}
}
pub fn ensure_migrations_dir(&self) -> Result<()> {
let path = self.migrations_path();
if !path.exists() {
std::fs::create_dir_all(&path).with_context(|| {
format!("Failed to create migrations directory: {}", path.display())
})?;
}
Ok(())
}
}
fn find_project_root() -> Result<PathBuf> {
let current = std::env::current_dir().context("Failed to get current directory")?;
let mut path = current.as_path();
loop {
if path.join("Cargo.toml").exists() {
return Ok(path.to_path_buf());
}
match path.parent() {
Some(parent) => path = parent,
None => break,
}
}
Ok(current)
}
pub fn generate_migration_id(name: &str) -> String {
let now = chrono::Utc::now();
let timestamp = now.format("%Y%m%d_%H%M%S");
let slug = slugify(name);
format!("m{}_{}", timestamp, slug)
}
fn slugify(name: &str) -> String {
name.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect::<String>()
.split('_')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("_")
}
fn parse_ormada_config(content: &str, config: &mut CliConfig) {
for line in content.lines() {
let line = line.trim();
if line.starts_with("migrations_dir") {
if let Some((_key, value)) = line.split_once('=') {
let value = value.trim().trim_matches('"');
config.migrations_dir = PathBuf::from(value);
}
}
if line.starts_with("url") && !line.starts_with('#') {
if let Some((_key, value)) = line.split_once('=') {
let value = value.trim().trim_matches('"');
config.database_url = Some(value.to_string());
}
}
}
}
fn parse_cargo_metadata(content: &str, config: &mut CliConfig) {
let mut in_ormada_section = false;
for line in content.lines() {
let line = line.trim();
if line.starts_with('[') {
in_ormada_section = line == "[package.metadata.ormada]"
|| line == "[package.metadata.ormada.migrations]";
continue;
}
if !in_ormada_section {
continue;
}
if line.starts_with("migrations_dir") || line.starts_with("dir") {
if let Some((_key, value)) = line.split_once('=') {
let value = value.trim().trim_matches('"');
config.migrations_dir = PathBuf::from(value);
}
}
if line.starts_with("database_url") {
if let Some((_key, value)) = line.split_once('=') {
let value = value.trim().trim_matches('"');
config.database_url = Some(value.to_string());
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_slugify() {
assert_eq!(slugify("Add Books Table"), "add_books_table");
assert_eq!(slugify("add-isbn-column"), "add_isbn_column");
assert_eq!(slugify("Initial Migration!"), "initial_migration");
}
#[test]
fn test_parse_ormada_config() {
let content = r#"
[migrations]
migrations_dir = "src/db/migrations"
[database]
url = "postgres://localhost/test"
"#;
let mut config = CliConfig::default();
parse_ormada_config(content, &mut config);
assert_eq!(config.migrations_dir, PathBuf::from("src/db/migrations"));
assert_eq!(config.database_url, Some("postgres://localhost/test".to_string()));
}
#[test]
fn test_parse_cargo_metadata() {
let content = r#"
[package]
name = "my-app"
version = "0.1.0"
[dependencies]
ormada = "0.1"
[package.metadata.ormada]
migrations_dir = "db/migrations"
database_url = "sqlite://./dev.db"
"#;
let mut config = CliConfig::default();
parse_cargo_metadata(content, &mut config);
assert_eq!(config.migrations_dir, PathBuf::from("db/migrations"));
assert_eq!(config.database_url, Some("sqlite://./dev.db".to_string()));
}
#[test]
fn test_parse_cargo_metadata_with_dir_shorthand() {
let content = r#"
[package.metadata.ormada]
dir = "migrations"
"#;
let mut config = CliConfig::default();
parse_cargo_metadata(content, &mut config);
assert_eq!(config.migrations_dir, PathBuf::from("migrations"));
}
#[test]
fn test_generate_migration_id() {
let id = generate_migration_id("add books");
assert!(id.starts_with("m20"));
assert!(id.contains("add_books"));
}
}