use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub struct MigrationTool {
source_format: TestFramework,
config: MigrationConfig,
}
#[derive(Debug, Clone)]
pub enum TestFramework {
Nbval,
Pytest,
PaperMill,
TestBook,
}
#[derive(Debug, Clone)]
pub struct MigrationConfig {
pub preserve_metadata: bool,
pub convert_asserts: bool,
pub generate_golden: bool,
pub output_format: OutputFormat,
}
#[derive(Debug, Clone)]
pub enum OutputFormat {
RuchyTestFile,
InlineMetadata,
SeparateConfig,
}
#[derive(Debug)]
pub struct MigrationResult {
pub converted_files: Vec<ConvertedFile>,
pub warnings: Vec<String>,
pub errors: Vec<String>,
pub stats: MigrationStats,
}
#[derive(Debug)]
pub struct ConvertedFile {
pub original_path: PathBuf,
pub new_path: PathBuf,
pub test_count: usize,
pub cell_count: usize,
}
#[derive(Debug)]
pub struct MigrationStats {
pub files_processed: usize,
pub tests_converted: usize,
pub cells_migrated: usize,
pub errors_encountered: usize,
}
#[derive(Deserialize)]
struct NbvalNotebook {
cells: Vec<NbvalCell>,
metadata: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct NbvalCell {
cell_type: String,
source: serde_json::Value,
outputs: Option<Vec<serde_json::Value>>,
metadata: Option<serde_json::Value>,
}
impl MigrationTool {
pub fn new(source: TestFramework) -> Self {
Self {
source_format: source,
config: MigrationConfig::default(),
}
}
pub fn with_config(source: TestFramework, config: MigrationConfig) -> Self {
Self {
source_format: source,
config,
}
}
pub fn migrate_directory(&self, input_dir: &Path, output_dir: &Path) -> MigrationResult {
let mut result = MigrationResult {
converted_files: Vec::new(),
warnings: Vec::new(),
errors: Vec::new(),
stats: MigrationStats {
files_processed: 0,
tests_converted: 0,
cells_migrated: 0,
errors_encountered: 0,
},
};
let files = self.find_test_files(input_dir);
result.stats.files_processed = files.len();
for file_path in files {
match self.convert_file(&file_path, output_dir) {
Ok(converted) => {
result.stats.tests_converted += converted.test_count;
result.stats.cells_migrated += converted.cell_count;
result.converted_files.push(converted);
}
Err(e) => {
result
.errors
.push(format!("Failed to convert {}: {}", file_path.display(), e));
result.stats.errors_encountered += 1;
}
}
}
result
}
pub fn convert_file(
&self,
input_path: &Path,
output_dir: &Path,
) -> Result<ConvertedFile, String> {
match self.source_format {
TestFramework::Nbval => self.convert_nbval_file(input_path, output_dir),
TestFramework::Pytest => self.convert_pytest_file(input_path, output_dir),
TestFramework::PaperMill => self.convert_papermill_file(input_path, output_dir),
TestFramework::TestBook => self.convert_testbook_file(input_path, output_dir),
}
}
fn convert_nbval_file(
&self,
input_path: &Path,
output_dir: &Path,
) -> Result<ConvertedFile, String> {
let content =
std::fs::read_to_string(input_path).map_err(|e| format!("Failed to read file: {e}"))?;
let notebook: NbvalNotebook =
serde_json::from_str(&content).map_err(|e| format!("Failed to parse notebook: {e}"))?;
let mut converted_cells = Vec::new();
let mut test_count = 0;
for cell in notebook.cells {
match self.convert_nbval_cell(&cell) {
Ok(ruchy_cell) => {
if ruchy_cell.has_tests() {
test_count += ruchy_cell.test_count();
}
converted_cells.push(ruchy_cell);
}
Err(e) => return Err(format!("Failed to convert cell: {e}")),
}
}
let output_path = self.generate_output_path(input_path, output_dir);
let ruchy_notebook = RuchyNotebook {
cells: converted_cells,
metadata: self.convert_metadata(notebook.metadata),
};
self.write_converted_notebook(&ruchy_notebook, &output_path)?;
Ok(ConvertedFile {
original_path: input_path.to_path_buf(),
new_path: output_path,
test_count,
cell_count: ruchy_notebook.cells.len(),
})
}
fn convert_nbval_cell(&self, cell: &NbvalCell) -> Result<RuchyCell, String> {
let source = match &cell.source {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Array(arr) => arr
.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(""),
_ => return Err("Invalid cell source format".to_string()),
};
let cell_type = match cell.cell_type.as_str() {
"code" => RuchyCellType::Code,
"markdown" => RuchyCellType::Markdown,
"raw" => RuchyCellType::Raw,
_ => RuchyCellType::Code,
};
let mut test_metadata = TestMetadata::new();
if let Some(outputs) = &cell.outputs {
for output in outputs {
if let Some(text) = output.get("text").and_then(|t| t.as_str()) {
test_metadata.add_expected_output(text.to_string());
}
}
}
Ok(RuchyCell {
cell_type,
source,
metadata: test_metadata,
})
}
fn convert_pytest_file(
&self,
input_path: &Path,
output_dir: &Path,
) -> Result<ConvertedFile, String> {
let content =
std::fs::read_to_string(input_path).map_err(|e| format!("Failed to read file: {e}"))?;
let test_functions = self.extract_pytest_functions(&content);
let mut cells = Vec::new();
for func in test_functions {
cells.push(RuchyCell {
cell_type: RuchyCellType::Code,
source: func.body,
metadata: TestMetadata::from_assertions(func.assertions),
});
}
let output_path = self.generate_output_path(input_path, output_dir);
let notebook = RuchyNotebook {
cells: cells.clone(),
metadata: HashMap::new(),
};
self.write_converted_notebook(¬ebook, &output_path)?;
Ok(ConvertedFile {
original_path: input_path.to_path_buf(),
new_path: output_path,
test_count: cells.len(),
cell_count: cells.len(),
})
}
fn convert_papermill_file(
&self,
_input_path: &Path,
_output_dir: &Path,
) -> Result<ConvertedFile, String> {
Err("PaperMill conversion not yet implemented".to_string())
}
fn convert_testbook_file(
&self,
_input_path: &Path,
_output_dir: &Path,
) -> Result<ConvertedFile, String> {
Err("TestBook conversion not yet implemented".to_string())
}
fn find_test_files(&self, dir: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
let extension = path.extension().and_then(|s| s.to_str());
let should_include = match self.source_format {
TestFramework::Nbval => extension == Some("ipynb"),
TestFramework::Pytest => {
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
extension == Some("py")
&& (name.starts_with("test_") || name.ends_with("_test.py"))
}
_ => false,
};
if should_include {
files.push(path);
}
}
}
}
files
}
fn generate_output_path(&self, input_path: &Path, output_dir: &Path) -> PathBuf {
let filename = input_path.file_stem().unwrap_or_default();
let extension = match self.config.output_format {
OutputFormat::RuchyTestFile => "ruchy",
_ => "ruchynb",
};
output_dir.join(format!("{}.{}", filename.to_string_lossy(), extension))
}
fn convert_metadata(&self, metadata: Option<serde_json::Value>) -> HashMap<String, String> {
let mut result = HashMap::new();
if let Some(meta) = metadata {
if let serde_json::Value::Object(map) = meta {
for (key, value) in map {
if let serde_json::Value::String(s) = value {
result.insert(key, s);
}
}
}
}
result
}
fn write_converted_notebook(
&self,
notebook: &RuchyNotebook,
output_path: &Path,
) -> Result<(), String> {
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create output directory: {e}"))?;
}
let content = match self.config.output_format {
OutputFormat::RuchyTestFile => self.serialize_as_rust_test(notebook),
_ => serde_json::to_string_pretty(notebook)
.map_err(|e| format!("Failed to serialize notebook: {e}"))?,
};
std::fs::write(output_path, content)
.map_err(|e| format!("Failed to write output file: {e}"))?;
Ok(())
}
fn serialize_as_rust_test(&self, _notebook: &RuchyNotebook) -> String {
let mut output = String::from("// Converted from nbval using ruchy migration tool\n\n");
output.push_str("use ruchy::notebook::testing::*;\n\n");
output
}
fn extract_pytest_functions(&self, content: &str) -> Vec<PytestFunction> {
let _ = content;
Vec::new()
}
}
impl Default for MigrationConfig {
fn default() -> Self {
Self {
preserve_metadata: true,
convert_asserts: true,
generate_golden: true,
output_format: OutputFormat::RuchyTestFile,
}
}
}
#[derive(Serialize, Deserialize)]
struct RuchyNotebook {
cells: Vec<RuchyCell>,
metadata: HashMap<String, String>,
}
#[derive(Serialize, Deserialize, Clone)]
struct RuchyCell {
cell_type: RuchyCellType,
source: String,
metadata: TestMetadata,
}
#[derive(Serialize, Deserialize, Clone)]
enum RuchyCellType {
Code,
Markdown,
Raw,
}
#[derive(Serialize, Deserialize, Clone)]
struct TestMetadata {
expected_outputs: Vec<String>,
test_type: String,
}
struct PytestFunction {
name: String,
body: String,
assertions: Vec<String>,
}
impl TestMetadata {
fn new() -> Self {
Self {
expected_outputs: Vec::new(),
test_type: "assertion".to_string(),
}
}
fn add_expected_output(&mut self, output: String) {
self.expected_outputs.push(output);
}
fn from_assertions(assertions: Vec<String>) -> Self {
Self {
expected_outputs: assertions,
test_type: "assertion".to_string(),
}
}
fn has_tests(&self) -> bool {
!self.expected_outputs.is_empty()
}
}
impl RuchyCell {
fn has_tests(&self) -> bool {
self.metadata.has_tests()
}
fn test_count(&self) -> usize {
self.metadata.expected_outputs.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_migration_tool_new() {
let tool = MigrationTool::new(TestFramework::Nbval);
assert!(matches!(tool.source_format, TestFramework::Nbval));
}
#[test]
fn test_migration_tool_with_config() {
let config = MigrationConfig {
preserve_metadata: false,
convert_asserts: false,
generate_golden: false,
output_format: OutputFormat::InlineMetadata,
};
let tool = MigrationTool::with_config(TestFramework::Pytest, config);
assert!(matches!(tool.source_format, TestFramework::Pytest));
assert!(!tool.config.preserve_metadata);
}
#[test]
fn test_migration_config_default() {
let config = MigrationConfig::default();
assert!(config.preserve_metadata);
assert!(config.convert_asserts);
assert!(config.generate_golden);
assert!(matches!(config.output_format, OutputFormat::RuchyTestFile));
}
#[test]
fn test_test_framework_variants() {
let _ = TestFramework::Nbval;
let _ = TestFramework::Pytest;
let _ = TestFramework::PaperMill;
let _ = TestFramework::TestBook;
}
#[test]
fn test_output_format_variants() {
let _ = OutputFormat::RuchyTestFile;
let _ = OutputFormat::InlineMetadata;
let _ = OutputFormat::SeparateConfig;
}
#[test]
fn test_find_test_files_empty_dir() {
let tool = MigrationTool::new(TestFramework::Nbval);
let temp_dir = tempdir().expect("operation should succeed in test");
let files = tool.find_test_files(temp_dir.path());
assert_eq!(files.len(), 0);
}
#[test]
fn test_find_test_files_nbval() {
let tool = MigrationTool::new(TestFramework::Nbval);
let temp_dir = tempdir().expect("operation should succeed in test");
fs::write(temp_dir.path().join("test.ipynb"), "{}")
.expect("operation should succeed in test");
fs::write(temp_dir.path().join("other.txt"), "data")
.expect("operation should succeed in test");
let files = tool.find_test_files(temp_dir.path());
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("test.ipynb"));
}
#[test]
fn test_find_test_files_pytest() {
let tool = MigrationTool::new(TestFramework::Pytest);
let temp_dir = tempdir().expect("operation should succeed in test");
fs::write(
temp_dir.path().join("test_example.py"),
"def test_foo(): pass",
)
.expect("operation should succeed in test");
fs::write(
temp_dir.path().join("example_test.py"),
"def test_bar(): pass",
)
.expect("operation should succeed in test");
fs::write(temp_dir.path().join("regular.py"), "print('hi')")
.expect("operation should succeed in test");
let files = tool.find_test_files(temp_dir.path());
assert_eq!(files.len(), 2);
}
#[test]
fn test_generate_output_path() {
let tool = MigrationTool::new(TestFramework::Nbval);
let output_dir = Path::new("/tmp/output");
let input_path = Path::new("/input/test.ipynb");
let output_path = tool.generate_output_path(input_path, output_dir);
assert!(output_path.to_string_lossy().contains("test.ruchy"));
}
#[test]
fn test_convert_metadata() {
let tool = MigrationTool::new(TestFramework::Nbval);
let result = tool.convert_metadata(None);
assert!(result.is_empty());
let value = serde_json::json!("string");
let result = tool.convert_metadata(Some(value));
assert!(result.is_empty());
let value = serde_json::json!({
"key1": "value1",
"key2": "value2",
"key3": 123 });
let result = tool.convert_metadata(Some(value));
assert_eq!(result.len(), 2);
assert_eq!(result.get("key1"), Some(&"value1".to_string()));
assert_eq!(result.get("key2"), Some(&"value2".to_string()));
}
#[test]
fn test_serialize_as_rust_test() {
let tool = MigrationTool::new(TestFramework::Nbval);
let notebook = RuchyNotebook {
cells: vec![],
metadata: HashMap::new(),
};
let result = tool.serialize_as_rust_test(¬ebook);
assert!(result.contains("// Converted from nbval"));
assert!(result.contains("use ruchy::notebook::testing::*;"));
}
#[test]
fn test_migrate_directory_empty() {
let tool = MigrationTool::new(TestFramework::Nbval);
let input_dir = tempdir().expect("operation should succeed in test");
let output_dir = tempdir().expect("operation should succeed in test");
let result = tool.migrate_directory(input_dir.path(), output_dir.path());
assert_eq!(result.stats.files_processed, 0);
assert_eq!(result.stats.tests_converted, 0);
assert_eq!(result.stats.cells_migrated, 0);
assert_eq!(result.stats.errors_encountered, 0);
assert_eq!(result.converted_files.len(), 0);
}
#[test]
fn test_convert_file_papermill() {
let tool = MigrationTool::new(TestFramework::PaperMill);
let input_path = Path::new("/nonexistent.ipynb");
let output_dir = Path::new("/tmp");
let result = tool.convert_file(input_path, output_dir);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"PaperMill conversion not yet implemented"
);
}
#[test]
fn test_convert_file_testbook() {
let tool = MigrationTool::new(TestFramework::TestBook);
let input_path = Path::new("/nonexistent.ipynb");
let output_dir = Path::new("/tmp");
let result = tool.convert_file(input_path, output_dir);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"TestBook conversion not yet implemented"
);
}
#[test]
fn test_extract_pytest_functions() {
let tool = MigrationTool::new(TestFramework::Pytest);
let _content = "def test_foo(): assert True";
assert!(matches!(tool.source_format, TestFramework::Pytest));
}
#[test]
fn test_ruchy_cell_has_tests() {
let cell = RuchyCell {
cell_type: RuchyCellType::Code,
source: "test".to_string(),
metadata: TestMetadata::new(),
};
assert!(!cell.has_tests());
let mut metadata = TestMetadata::new();
metadata.add_expected_output("output".to_string());
let cell = RuchyCell {
cell_type: RuchyCellType::Code,
source: "test".to_string(),
metadata,
};
assert!(cell.has_tests());
assert_eq!(cell.test_count(), 1);
}
#[test]
fn test_test_metadata() {
let mut metadata = TestMetadata::new();
assert!(!metadata.has_tests());
assert_eq!(metadata.test_type, "assertion");
metadata.add_expected_output("output1".to_string());
metadata.add_expected_output("output2".to_string());
assert!(metadata.has_tests());
assert_eq!(metadata.expected_outputs.len(), 2);
}
#[test]
fn test_test_metadata_from_assertions() {
let assertions = vec!["assert True".to_string(), "assert False".to_string()];
let metadata = TestMetadata::from_assertions(assertions.clone());
assert_eq!(metadata.expected_outputs, assertions);
assert_eq!(metadata.test_type, "assertion");
}
#[test]
fn test_ruchy_cell_type_variants() {
let _ = RuchyCellType::Code;
let _ = RuchyCellType::Markdown;
let _ = RuchyCellType::Raw;
}
#[test]
fn test_migration_result_fields() {
let result = MigrationResult {
converted_files: vec![],
warnings: vec!["warning".to_string()],
errors: vec!["error".to_string()],
stats: MigrationStats {
files_processed: 1,
tests_converted: 2,
cells_migrated: 3,
errors_encountered: 4,
},
};
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.errors.len(), 1);
assert_eq!(result.stats.files_processed, 1);
}
#[test]
fn test_converted_file_fields() {
let file = ConvertedFile {
original_path: PathBuf::from("/original.ipynb"),
new_path: PathBuf::from("/new.ruchy"),
test_count: 5,
cell_count: 10,
};
assert_eq!(file.test_count, 5);
assert_eq!(file.cell_count, 10);
}
#[test]
fn test_pytest_function_fields() {
let func = PytestFunction {
name: "test_example".to_string(),
body: "assert True".to_string(),
assertions: vec!["assert True".to_string()],
};
assert_eq!(func.name, "test_example");
assert_eq!(func.body, "assert True");
assert_eq!(func.assertions.len(), 1);
}
}