Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changepacks/changepack_log_sGDzSkU-VNjgUx_M-V6sq.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"changes":{"crates/vespertide-macro/Cargo.toml":"Patch","crates/vespertide-config/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch","crates/vespertide-query/Cargo.toml":"Patch","crates/vespertide-loader/Cargo.toml":"Patch","crates/vespertide/Cargo.toml":"Patch","crates/vespertide-naming/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch","crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide-exporter/Cargo.toml":"Patch"},"note":"Implement jpa","date":"2026-03-24T10:44:57.164009900Z"}
20 changes: 10 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

83 changes: 82 additions & 1 deletion crates/vespertide-cli/src/commands/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub enum OrmArg {
Seaorm,
Sqlalchemy,
Sqlmodel,
Jpa,
}

impl From<OrmArg> for Orm {
Expand All @@ -23,6 +24,7 @@ impl From<OrmArg> for Orm {
OrmArg::Seaorm => Orm::SeaOrm,
OrmArg::Sqlalchemy => Orm::SqlAlchemy,
OrmArg::Sqlmodel => Orm::SqlModel,
OrmArg::Jpa => Orm::Jpa,
}
}
}
Expand Down Expand Up @@ -133,6 +135,7 @@ async fn clean_export_dir(root: &Path, orm: Orm) -> Result<()> {
let ext = match orm {
Orm::SeaOrm => "rs",
Orm::SqlAlchemy | Orm::SqlModel => "py",
Orm::Jpa => "java",
};

clean_dir_recursive(root, ext).await?;
Expand Down Expand Up @@ -224,13 +227,32 @@ fn build_output_path(root: &Path, rel_path: &Path, orm: Orm) -> PathBuf {
let ext = match orm {
Orm::SeaOrm => "rs",
Orm::SqlAlchemy | Orm::SqlModel => "py",
Orm::Jpa => "java",
};
out.set_file_name(format!("{}.{}", sanitized, ext));
// Java requires filename to match PascalCase class name
let file_stem = if matches!(orm, Orm::Jpa) {
to_pascal_case(&sanitized)
} else {
sanitized
};
out.set_file_name(format!("{}.{}", file_stem, ext));
}

out
}

fn to_pascal_case(s: &str) -> String {
s.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
})
.collect()
}

fn sanitize_filename(name: &str) -> String {
name.chars()
.map(|ch| {
Expand Down Expand Up @@ -581,6 +603,7 @@ mod tests {
#[case(OrmArg::Seaorm, Orm::SeaOrm)]
#[case(OrmArg::Sqlalchemy, Orm::SqlAlchemy)]
#[case(OrmArg::Sqlmodel, Orm::SqlModel)]
#[case(OrmArg::Jpa, Orm::Jpa)]
fn orm_arg_maps_to_enum(#[case] arg: OrmArg, #[case] expected: Orm) {
assert_eq!(Orm::from(arg), expected);
}
Expand Down Expand Up @@ -745,6 +768,23 @@ mod tests {
assert!(!root.join("model.py").exists());
}

#[tokio::test]
async fn clean_export_dir_removes_java_files_for_jpa() {
let tmp = tempdir().unwrap();
let root = tmp.path().join("export_dir");
std_fs::create_dir_all(&root).unwrap();

std_fs::write(root.join("User.java"), "// java entity").unwrap();
std_fs::write(root.join("Order.java"), "// java entity").unwrap();
std_fs::write(root.join("keep.rs"), "// keep this").unwrap();

clean_export_dir(&root, Orm::Jpa).await.unwrap();

assert!(!root.join("User.java").exists());
assert!(!root.join("Order.java").exists());
assert!(root.join("keep.rs").exists());
}

#[tokio::test]
async fn clean_export_dir_handles_missing_directory() {
let tmp = tempdir().unwrap();
Expand Down Expand Up @@ -806,4 +846,45 @@ mod tests {
let result = clean_dir_recursive(&file_path, "rs").await;
assert!(result.is_ok());
}

#[test]
fn build_output_path_jpa_uses_pascal_case_java_extension() {
use std::path::Path;
let root = Path::new("src/models");

// snake_case model → PascalCase .java
let rel_path = Path::new("order_item.json");
let out = build_output_path(root, rel_path, Orm::Jpa);
assert_eq!(out, Path::new("src/models/OrderItem.java"));

// Single word
let rel_path2 = Path::new("users.json");
let out2 = build_output_path(root, rel_path2, Orm::Jpa);
assert_eq!(out2, Path::new("src/models/Users.java"));

// Nested path
let rel_path3 = Path::new("blog/post_comment.yaml");
let out3 = build_output_path(root, rel_path3, Orm::Jpa);
assert_eq!(out3, Path::new("src/models/blog/PostComment.java"));
}

#[test]
fn build_output_path_jpa_strips_vespertide_suffix() {
use std::path::Path;
let root = Path::new("src/models");

let rel_path = Path::new("user.vespertide.json");
let out = build_output_path(root, rel_path, Orm::Jpa);
assert_eq!(out, Path::new("src/models/User.java"));
}

#[rstest]
#[case("order_item", "OrderItem")]
#[case("users", "Users")]
#[case("a", "A")]
#[case("user_profile_image", "UserProfileImage")]
#[case("a__b", "AB")]
fn test_to_pascal_case(#[case] input: &str, #[case] expected: &str) {
assert_eq!(to_pascal_case(input), expected);
}
}
Loading