diff --git a/src/cli/args.rs b/src/cli/args.rs index 99481f9..9f1517b 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -78,6 +78,18 @@ pub enum Commands { with_tests: bool, }, + /// Add FDML entities directly to specification files + Add { + #[command(subcommand)] + operation: AddCommands, + }, + + /// List FDML entities from specification files + List { + #[command(subcommand)] + operation: ListCommands, + }, + /// Run migration operations Migrate { #[command(subcommand)] @@ -179,4 +191,147 @@ impl Cli { pub fn parse_args() -> Self { Self::parse() } +} + +#[derive(Subcommand)] +pub enum AddCommands { + /// Add a new feature + Feature { + /// Feature ID + id: String, + + /// Feature title + #[arg(long)] + title: String, + + /// Feature description + #[arg(long)] + description: Option, + + /// Target FDML file to modify + #[arg(short, long)] + target: Option, + }, + + /// Add a new entity + Entity { + /// Entity ID + id: String, + + /// Entity name + #[arg(long)] + name: String, + + /// Entity description + #[arg(long)] + description: Option, + + /// Target FDML file to modify + #[arg(short, long)] + target: Option, + }, + + /// Add a new action + Action { + /// Action ID + id: String, + + /// Action name + #[arg(long)] + name: String, + + /// Action description + #[arg(long)] + description: Option, + + /// Target FDML file to modify + #[arg(short, long)] + target: Option, + }, + + /// Add a new constraint + Constraint { + /// Constraint ID + id: String, + + /// Constraint name + #[arg(long)] + name: String, + + /// Constraint condition/rule + #[arg(long)] + condition: String, + + /// What the constraint applies to + #[arg(long)] + applies_to: String, + + /// Constraint description + #[arg(long)] + description: Option, + + /// Error message for constraint violations + #[arg(long)] + message: Option, + + /// Target FDML file to modify + #[arg(short, long)] + target: Option, + }, + + /// Add a field to an entity + Field { + /// Entity ID to add field to + entity_id: String, + + /// Field name + field_name: String, + + /// Field type + #[arg(long)] + field_type: String, + + /// Whether field is required + #[arg(long)] + required: bool, + + /// Default value for field + #[arg(long)] + default: Option, + + /// Target FDML file to modify + #[arg(short, long)] + target: Option, + }, +} + +#[derive(Subcommand)] +pub enum ListCommands { + /// List all features + Features { + /// Target FDML file to read from + #[arg(short, long)] + target: Option, + }, + + /// List all entities + Entities { + /// Target FDML file to read from + #[arg(short, long)] + target: Option, + }, + + /// List all actions + Actions { + /// Target FDML file to read from + #[arg(short, long)] + target: Option, + }, + + /// List all constraints + Constraints { + /// Target FDML file to read from + #[arg(short, long)] + target: Option, + }, } \ No newline at end of file diff --git a/src/cli/commands.rs b/src/cli/commands.rs index be56088..f6d93d8 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -1,13 +1,13 @@ -use crate::cli::args::{Cli, Commands, MigrateCommands, TraceCommands}; +use crate::cli::args::{Cli, Commands, MigrateCommands, TraceCommands, AddCommands, ListCommands}; use crate::error::{print_error, print_info, print_success, print_warning, Result}; use crate::parser::{parse_fdml_yaml, parse_fdml}; use crate::project::ProjectInitializer; use crate::validator::Validator; use crate::generators::{create_generator, GeneratorConfig}; use crate::generators::test_gen::TestGenerator; -use crate::migration::MigrationRunner; +use crate::migration::{MigrationRunner, Migration, MigrationOperation}; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; pub struct CommandRunner { verbose: bool, @@ -26,6 +26,8 @@ impl CommandRunner { Commands::Generate { input, language, output, template, with_tests } => { self.run_generate(input, language, output, template, with_tests) }, + Commands::Add { operation } => self.run_add(operation), + Commands::List { operation } => self.run_list(operation), Commands::Migrate { operation } => self.run_migrate(operation), Commands::Trace { operation } => self.run_trace(operation), } @@ -348,6 +350,338 @@ impl CommandRunner { } Ok(()) } + + fn run_add(&self, operation: AddCommands) -> Result<()> { + match operation { + AddCommands::Feature { id, title, description, target } => { + if self.verbose { + print_info(&format!("Adding feature: {} - {}", id, title)); + if let Some(ref target_file) = target { + print_info(&format!("Target FDML file: {}", target_file)); + } + } + + let op = MigrationOperation::AddFeature { + id: id.clone(), + title: title.clone(), + description, + scenarios: None, + }; + + self.apply_single_operation(op, target)?; + print_success(&format!("Successfully added feature: {}", id)); + }, + + AddCommands::Entity { id, name, description, target } => { + if self.verbose { + print_info(&format!("Adding entity: {} - {}", id, name)); + if let Some(ref target_file) = target { + print_info(&format!("Target FDML file: {}", target_file)); + } + } + + let op = MigrationOperation::AddEntity { + id: id.clone(), + name: name.clone(), + description, + }; + + self.apply_single_operation(op, target)?; + print_success(&format!("Successfully added entity: {}", id)); + }, + + AddCommands::Action { id, name, description, target } => { + if self.verbose { + print_info(&format!("Adding action: {} - {}", id, name)); + if let Some(ref target_file) = target { + print_info(&format!("Target FDML file: {}", target_file)); + } + } + + let op = MigrationOperation::AddAction { + id: id.clone(), + name: name.clone(), + description, + }; + + self.apply_single_operation(op, target)?; + print_success(&format!("Successfully added action: {}", id)); + }, + + AddCommands::Constraint { id, name, condition, applies_to, description, message, target } => { + if self.verbose { + print_info(&format!("Adding constraint: {} - {}", id, name)); + print_info(&format!("Condition: {} (applies to: {})", condition, applies_to)); + if let Some(ref target_file) = target { + print_info(&format!("Target FDML file: {}", target_file)); + } + } + + let op = MigrationOperation::AddConstraint { + id: id.clone(), + name: name.clone(), + description, + condition: condition.clone(), + applies_to: applies_to.clone(), + message, + }; + + self.apply_single_operation(op, target)?; + print_success(&format!("Successfully added constraint: {}", id)); + }, + + AddCommands::Field { entity_id, field_name, field_type, required, default, target } => { + if self.verbose { + print_info(&format!("Adding field: {} ({}) to entity: {}", field_name, field_type, entity_id)); + if let Some(ref target_file) = target { + print_info(&format!("Target FDML file: {}", target_file)); + } + } + + let default_value = default.map(|d| { + // Try to parse as different types + if let Ok(b) = d.parse::() { + serde_json::Value::Bool(b) + } else if let Ok(n) = d.parse::() { + serde_json::Value::Number(serde_json::Number::from_f64(n).unwrap()) + } else { + serde_json::Value::String(d) + } + }); + + let op = MigrationOperation::AddField { + entity_id: entity_id.clone(), + field_name: field_name.clone(), + field_type: field_type.clone(), + required: Some(required), + default: default_value, + }; + + self.apply_single_operation(op, target)?; + print_success(&format!("Successfully added field {} to entity {}", field_name, entity_id)); + }, + } + Ok(()) + } + + fn run_list(&self, operation: ListCommands) -> Result<()> { + match operation { + ListCommands::Features { target } => { + if self.verbose { + print_info("Listing features"); + if let Some(ref target_file) = target { + print_info(&format!("From FDML file: {}", target_file)); + } + } + + let document = self.load_fdml_document(target)?; + + if document.features.is_empty() { + print_info("No features found"); + } else { + println!("Features ({}):", document.features.len()); + for feature in &document.features { + println!(" • {} - {}", feature.id, feature.title); + if let Some(ref desc) = feature.description { + println!(" Description: {}", desc); + } + if !feature.scenarios.is_empty() { + println!(" Scenarios: {}", feature.scenarios.len()); + } + } + } + }, + + ListCommands::Entities { target } => { + if self.verbose { + print_info("Listing entities"); + if let Some(ref target_file) = target { + print_info(&format!("From FDML file: {}", target_file)); + } + } + + let document = self.load_fdml_document(target)?; + + if document.entities.is_empty() { + print_info("No entities found"); + } else { + println!("Entities ({}):", document.entities.len()); + for entity in &document.entities { + let name = entity.name.as_deref().unwrap_or(&entity.id); + println!(" • {} - {}", entity.id, name); + if let Some(ref desc) = entity.description { + println!(" Description: {}", desc); + } + if !entity.fields.is_empty() { + println!(" Fields: {}", entity.fields.len()); + } + } + } + }, + + ListCommands::Actions { target } => { + if self.verbose { + print_info("Listing actions"); + if let Some(ref target_file) = target { + print_info(&format!("From FDML file: {}", target_file)); + } + } + + let document = self.load_fdml_document(target)?; + + if document.actions.is_empty() { + print_info("No actions found"); + } else { + println!("Actions ({}):", document.actions.len()); + for action in &document.actions { + let name = action.name.as_deref().unwrap_or(&action.id); + println!(" • {} - {}", action.id, name); + if let Some(ref desc) = action.description { + println!(" Description: {}", desc); + } + } + } + }, + + ListCommands::Constraints { target } => { + if self.verbose { + print_info("Listing constraints"); + if let Some(ref target_file) = target { + print_info(&format!("From FDML file: {}", target_file)); + } + } + + let document = self.load_fdml_document(target)?; + + if document.constraints.is_empty() { + print_info("No constraints found"); + } else { + println!("Constraints ({}):", document.constraints.len()); + for constraint in &document.constraints { + println!(" • {} - {}", constraint.id, constraint.name); + if let Some(ref desc) = constraint.description { + println!(" Description: {}", desc); + } + println!(" Rule: {}", constraint.rule); + } + } + }, + } + Ok(()) + } + + /// Apply a single migration operation directly (used for add commands) + fn apply_single_operation(&self, operation: MigrationOperation, target: Option) -> Result<()> { + // Determine target file + let target_file = if let Some(target_file) = target { + let path = PathBuf::from(target_file); + // Check if the target file exists for direct operations + if !path.exists() { + return Err(crate::error::FdmlError::project_error( + format!("Target file '{}' does not exist. Use 'fdml init' to create a new project first.", path.display()) + )); + } + path + } else { + // Find default FDML file in current directory + let current_dir = std::env::current_dir()?; + self.find_default_fdml_file(¤t_dir)?.ok_or_else(|| { + crate::error::FdmlError::project_error( + "No target file specified and no FDML file found in current directory. Use --target to specify a file.".to_string() + ) + })? + }; + + // Create a temporary migration directory for this operation with a unique name + let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S_%f"); + let temp_dir = std::env::temp_dir().join(format!("fdml_direct_{}", timestamp)); + std::fs::create_dir_all(&temp_dir)?; + + // Create temporary migration runner + let runner = MigrationRunner::new(&temp_dir).with_target_file(&target_file); + + // Validate the operation + runner.validate_operation(&operation)?; + + // Create a temporary migration file + let migration_id = format!("direct_{}", timestamp); + let migration = Migration { + id: migration_id.clone(), + title: Some("Direct CLI operation".to_string()), + description: Some("Migration created by direct CLI command".to_string()), + up: vec![operation], + down: vec![], // We don't need rollback for direct operations + dependencies: None, + }; + + // Write the temporary migration file + let migration_file = temp_dir.join(format!("{}.yaml", migration_id)); + let migration_content = serde_yaml::to_string(&migration)?; + std::fs::write(&migration_file, migration_content)?; + + // Apply the migration using the existing apply_migrations method + let applied = runner.apply_migrations(false)?; + + if applied.is_empty() { + print_warning("No operations were applied"); + } + + // Clean up temporary directory + std::fs::remove_dir_all(&temp_dir).ok(); + + Ok(()) + } + + /// Load FDML document from target file or find default + fn load_fdml_document(&self, target: Option) -> Result { + let file_path = if let Some(target_file) = target { + PathBuf::from(target_file) + } else { + let current_dir = std::env::current_dir()?; + self.find_default_fdml_file(¤t_dir)?.ok_or_else(|| { + crate::error::FdmlError::project_error( + "No target file specified and no FDML file found in current directory. Use --target to specify a file.".to_string() + ) + })? + }; + + let content = fs::read_to_string(&file_path).map_err(|e| { + crate::error::FdmlError::project_error(format!("Failed to read file '{}': {}", file_path.display(), e)) + })?; + + parse_fdml_yaml(&content) + } + + /// Find the default FDML file in a directory + fn find_default_fdml_file(&self, dir: &Path) -> Result> { + let possible_files = [ + "spec.fdml", + "specification.fdml", + "main.fdml", + "app.fdml" + ]; + + for filename in &possible_files { + let path = dir.join(filename); + if path.exists() { + return Ok(Some(path)); + } + } + + // Look for any .fdml file + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries { + if let Ok(entry) = entry { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("fdml") { + return Ok(Some(path)); + } + } + } + } + + Ok(None) + } } impl Default for CommandRunner { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 260f42f..3ad7dbd 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,5 +1,5 @@ pub mod args; pub mod commands; -pub use args::{Cli, Commands}; +pub use args::{Cli, Commands, AddCommands, ListCommands}; pub use commands::CommandRunner; \ No newline at end of file diff --git a/src/migration/runner.rs b/src/migration/runner.rs index 70bd920..a6d3908 100644 --- a/src/migration/runner.rs +++ b/src/migration/runner.rs @@ -4,7 +4,7 @@ use std::fs; use std::path::{Path, PathBuf}; use crate::error::Result; use crate::parser::{parse_fdml_yaml}; -use crate::parser::ast::{FdmlDocument, Feature, Scenario, Field, Value}; +use crate::parser::ast::{FdmlDocument, Feature, Scenario, Field, Value, Entity, Action, Constraint}; use std::collections::HashSet; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -31,11 +31,44 @@ pub enum MigrationOperation { RemoveFeature { id: String, }, + #[serde(rename = "add_entity")] + AddEntity { + id: String, + name: String, + description: Option, + }, + #[serde(rename = "remove_entity")] + RemoveEntity { + id: String, + }, #[serde(rename = "modify_entity")] ModifyEntity { id: String, changes: EntityChanges, }, + #[serde(rename = "add_action")] + AddAction { + id: String, + name: String, + description: Option, + }, + #[serde(rename = "remove_action")] + RemoveAction { + id: String, + }, + #[serde(rename = "add_constraint")] + AddConstraint { + id: String, + name: String, + description: Option, + condition: String, + applies_to: String, + message: Option, + }, + #[serde(rename = "remove_constraint")] + RemoveConstraint { + id: String, + }, #[serde(rename = "add_field")] AddField { entity_id: String, @@ -479,12 +512,30 @@ impl MigrationRunner { MigrationOperation::RemoveFeature { id } => { println!(" - Remove feature: {}", id); }, + MigrationOperation::AddEntity { id, name, .. } => { + println!(" + Add entity: {} ({})", id, name); + }, + MigrationOperation::RemoveEntity { id } => { + println!(" - Remove entity: {}", id); + }, MigrationOperation::ModifyEntity { id, changes } => { println!(" ~ Modify entity: {}", id); if let Some(name) = &changes.name { println!(" - Change name to: {}", name); } }, + MigrationOperation::AddAction { id, name, .. } => { + println!(" + Add action: {} ({})", id, name); + }, + MigrationOperation::RemoveAction { id } => { + println!(" - Remove action: {}", id); + }, + MigrationOperation::AddConstraint { id, name, condition, applies_to, .. } => { + println!(" + Add constraint: {} ({}) - {} applies to {}", id, name, condition, applies_to); + }, + MigrationOperation::RemoveConstraint { id } => { + println!(" - Remove constraint: {}", id); + }, MigrationOperation::AddField { entity_id, field_name, field_type, .. } => { println!(" + Add field '{}' ({}) to entity '{}'", field_name, field_type, entity_id); }, @@ -520,6 +571,48 @@ impl MigrationRunner { )); } }, + MigrationOperation::AddEntity { id, name, .. } => { + if id.trim().is_empty() || name.trim().is_empty() { + return Err(crate::error::FdmlError::migration_error( + "AddEntity operation requires non-empty id and name".to_string() + )); + } + }, + MigrationOperation::RemoveEntity { id } => { + if id.trim().is_empty() { + return Err(crate::error::FdmlError::migration_error( + "RemoveEntity operation requires non-empty id".to_string() + )); + } + }, + MigrationOperation::AddAction { id, name, .. } => { + if id.trim().is_empty() || name.trim().is_empty() { + return Err(crate::error::FdmlError::migration_error( + "AddAction operation requires non-empty id and name".to_string() + )); + } + }, + MigrationOperation::RemoveAction { id } => { + if id.trim().is_empty() { + return Err(crate::error::FdmlError::migration_error( + "RemoveAction operation requires non-empty id".to_string() + )); + } + }, + MigrationOperation::AddConstraint { id, name, condition, applies_to, .. } => { + if id.trim().is_empty() || name.trim().is_empty() || condition.trim().is_empty() || applies_to.trim().is_empty() { + return Err(crate::error::FdmlError::migration_error( + "AddConstraint operation requires non-empty id, name, condition, and applies_to".to_string() + )); + } + }, + MigrationOperation::RemoveConstraint { id } => { + if id.trim().is_empty() { + return Err(crate::error::FdmlError::migration_error( + "RemoveConstraint operation requires non-empty id".to_string() + )); + } + }, MigrationOperation::AddField { entity_id, field_name, field_type, .. } => { if entity_id.trim().is_empty() || field_name.trim().is_empty() || field_type.trim().is_empty() { return Err(crate::error::FdmlError::migration_error( @@ -573,6 +666,25 @@ impl MigrationRunner { println!(" - Removing feature: {}", id); document.features.retain(|f| f.id != *id); }, + + MigrationOperation::AddEntity { id, name, description } => { + println!(" + Adding entity: {} - {}", id, name); + + let entity = Entity { + id: id.clone(), + name: Some(name.clone()), + description: description.clone(), + fields: Vec::new(), + relationships: None, + }; + + document.entities.push(entity); + }, + + MigrationOperation::RemoveEntity { id } => { + println!(" - Removing entity: {}", id); + document.entities.retain(|e| e.id != *id); + }, MigrationOperation::ModifyEntity { id, changes } => { println!(" ~ Modifying entity: {}", id); @@ -592,6 +704,54 @@ impl MigrationRunner { ))); } }, + + MigrationOperation::AddAction { id, name, description } => { + println!(" + Adding action: {} - {}", id, name); + + let action = Action { + id: id.clone(), + name: Some(name.clone()), + description: description.clone(), + input: None, + output: None, + side_effects: None, + preconditions: None, + postconditions: None, + }; + + document.actions.push(action); + }, + + MigrationOperation::RemoveAction { id } => { + println!(" - Removing action: {}", id); + document.actions.retain(|a| a.id != *id); + }, + + MigrationOperation::AddConstraint { id, name, description, condition, applies_to, message } => { + println!(" + Adding constraint: {} - {}", id, name); + + let constraint = Constraint { + id: id.clone(), + name: name.clone(), + description: description.clone(), + constraint_type: "rule".to_string(), + rule: condition.clone(), + entities: None, + actions: None, + }; + + document.constraints.push(constraint); + println!(" - Condition: {}", condition); + println!(" - Applies to: {}", applies_to); + if let Some(msg) = message { + println!(" - Message: {}", msg); + } + }, + + MigrationOperation::RemoveConstraint { id } => { + println!(" - Removing constraint: {}", id); + document.constraints.retain(|c| c.id != *id); + }, MigrationOperation::AddField { entity_id, field_name, field_type, required, default } => { println!(" + Adding field {} ({}) to entity {}", field_name, field_type, entity_id); diff --git a/src/migration/tests.rs b/src/migration/tests.rs index a3df129..c332dc4 100644 --- a/src/migration/tests.rs +++ b/src/migration/tests.rs @@ -369,4 +369,182 @@ features: }; assert!(runner.validate_operation(&invalid_field_op).is_err()); } + + #[test] + fn test_add_entity_operation() { + let temp_dir = TempDir::new().unwrap(); + let migration_dir = temp_dir.path().join("migrations"); + let target_file = temp_dir.path().join("test.fdml"); + fs::create_dir_all(&migration_dir).unwrap(); + + create_test_fdml_file(&target_file); + + let migration = Migration { + id: "001_add_entity".to_string(), + title: Some("Add Entity".to_string()), + description: None, + up: vec![MigrationOperation::AddEntity { + id: "new_entity".to_string(), + name: "New Entity".to_string(), + description: Some("A new entity".to_string()), + }], + down: vec![MigrationOperation::RemoveEntity { + id: "new_entity".to_string(), + }], + dependencies: None, + }; + + create_test_migration_file(&migration_dir, "001_add_entity.yaml", &migration); + + let runner = MigrationRunner::new(&migration_dir).with_target_file(&target_file); + let applied = runner.apply_migrations(false).unwrap(); + + assert_eq!(applied.len(), 1); + + // Verify entity was added + let content = fs::read_to_string(&target_file).unwrap(); + assert!(content.contains("new_entity")); + assert!(content.contains("New Entity")); + } + + #[test] + fn test_add_action_operation() { + let temp_dir = TempDir::new().unwrap(); + let migration_dir = temp_dir.path().join("migrations"); + let target_file = temp_dir.path().join("test.fdml"); + fs::create_dir_all(&migration_dir).unwrap(); + + create_test_fdml_file(&target_file); + + let migration = Migration { + id: "001_add_action".to_string(), + title: Some("Add Action".to_string()), + description: None, + up: vec![MigrationOperation::AddAction { + id: "new_action".to_string(), + name: "New Action".to_string(), + description: Some("A new action".to_string()), + }], + down: vec![MigrationOperation::RemoveAction { + id: "new_action".to_string(), + }], + dependencies: None, + }; + + create_test_migration_file(&migration_dir, "001_add_action.yaml", &migration); + + let runner = MigrationRunner::new(&migration_dir).with_target_file(&target_file); + let applied = runner.apply_migrations(false).unwrap(); + + assert_eq!(applied.len(), 1); + + // Verify action was added + let content = fs::read_to_string(&target_file).unwrap(); + assert!(content.contains("new_action")); + assert!(content.contains("New Action")); + } + + #[test] + fn test_add_constraint_operation() { + let temp_dir = TempDir::new().unwrap(); + let migration_dir = temp_dir.path().join("migrations"); + let target_file = temp_dir.path().join("test.fdml"); + fs::create_dir_all(&migration_dir).unwrap(); + + create_test_fdml_file(&target_file); + + let migration = Migration { + id: "001_add_constraint".to_string(), + title: Some("Add Constraint".to_string()), + description: None, + up: vec![MigrationOperation::AddConstraint { + id: "new_constraint".to_string(), + name: "New Constraint".to_string(), + description: Some("A new constraint".to_string()), + condition: "unique(email)".to_string(), + applies_to: "user.email".to_string(), + message: Some("Email must be unique".to_string()), + }], + down: vec![MigrationOperation::RemoveConstraint { + id: "new_constraint".to_string(), + }], + dependencies: None, + }; + + create_test_migration_file(&migration_dir, "001_add_constraint.yaml", &migration); + + let runner = MigrationRunner::new(&migration_dir).with_target_file(&target_file); + let applied = runner.apply_migrations(false).unwrap(); + + assert_eq!(applied.len(), 1); + + // Verify constraint was added + let content = fs::read_to_string(&target_file).unwrap(); + assert!(content.contains("new_constraint")); + assert!(content.contains("New Constraint")); + assert!(content.contains("unique(email)")); + } + + #[test] + fn test_new_operation_validation() { + let temp_dir = TempDir::new().unwrap(); + let migration_dir = temp_dir.path().join("migrations"); + fs::create_dir_all(&migration_dir).unwrap(); + + let runner = MigrationRunner::new(&migration_dir); + + // Valid AddEntity operation + let valid_entity_op = MigrationOperation::AddEntity { + id: "valid_entity".to_string(), + name: "Valid Entity".to_string(), + description: None, + }; + assert!(runner.validate_operation(&valid_entity_op).is_ok()); + + // Invalid AddEntity operation (empty id) + let invalid_entity_op = MigrationOperation::AddEntity { + id: "".to_string(), + name: "Invalid Entity".to_string(), + description: None, + }; + assert!(runner.validate_operation(&invalid_entity_op).is_err()); + + // Valid AddAction operation + let valid_action_op = MigrationOperation::AddAction { + id: "valid_action".to_string(), + name: "Valid Action".to_string(), + description: None, + }; + assert!(runner.validate_operation(&valid_action_op).is_ok()); + + // Invalid AddAction operation (empty name) + let invalid_action_op = MigrationOperation::AddAction { + id: "valid_id".to_string(), + name: "".to_string(), + description: None, + }; + assert!(runner.validate_operation(&invalid_action_op).is_err()); + + // Valid AddConstraint operation + let valid_constraint_op = MigrationOperation::AddConstraint { + id: "valid_constraint".to_string(), + name: "Valid Constraint".to_string(), + description: None, + condition: "valid condition".to_string(), + applies_to: "entity.field".to_string(), + message: None, + }; + assert!(runner.validate_operation(&valid_constraint_op).is_ok()); + + // Invalid AddConstraint operation (empty condition) + let invalid_constraint_op = MigrationOperation::AddConstraint { + id: "valid_id".to_string(), + name: "Valid Name".to_string(), + description: None, + condition: "".to_string(), + applies_to: "entity.field".to_string(), + message: None, + }; + assert!(runner.validate_operation(&invalid_constraint_op).is_err()); + } } \ No newline at end of file diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 369b13c..6e9baec 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -589,4 +589,304 @@ fn test_full_project_lifecycle() { .arg(ecommerce_path) .assert() .success(); +} + +#[test] +fn test_add_entity_command() { + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.fdml"); + + // Create a minimal FDML file + fs::write(&test_file, r#" +metadata: + version: "1.3" + author: "Test" + description: "Test file" + +entities: [] +actions: [] +features: [] +constraints: [] +"#).unwrap(); + + // Test adding an entity + let mut cmd = Command::cargo_bin("fdml").unwrap(); + cmd.arg("add") + .arg("entity") + .arg("test_entity") + .arg("--name") + .arg("Test Entity") + .arg("--description") + .arg("A test entity") + .arg("--target") + .arg(&test_file) + .assert() + .success() + .stdout(predicate::str::contains("Successfully added entity: test_entity")); + + // Verify the entity was added + let content = fs::read_to_string(&test_file).unwrap(); + assert!(content.contains("test_entity")); + assert!(content.contains("Test Entity")); +} + +#[test] +fn test_add_action_command() { + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.fdml"); + + // Create a minimal FDML file + fs::write(&test_file, r#" +metadata: + version: "1.3" + author: "Test" + description: "Test file" + +entities: [] +actions: [] +features: [] +constraints: [] +"#).unwrap(); + + // Test adding an action + let mut cmd = Command::cargo_bin("fdml").unwrap(); + cmd.arg("add") + .arg("action") + .arg("test_action") + .arg("--name") + .arg("Test Action") + .arg("--description") + .arg("A test action") + .arg("--target") + .arg(&test_file) + .assert() + .success() + .stdout(predicate::str::contains("Successfully added action: test_action")); + + // Verify the action was added + let content = fs::read_to_string(&test_file).unwrap(); + assert!(content.contains("test_action")); + assert!(content.contains("Test Action")); +} + +#[test] +fn test_add_feature_command() { + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.fdml"); + + // Create a minimal FDML file + fs::write(&test_file, r#" +metadata: + version: "1.3" + author: "Test" + description: "Test file" + +entities: [] +actions: [] +features: [] +constraints: [] +"#).unwrap(); + + // Test adding a feature + let mut cmd = Command::cargo_bin("fdml").unwrap(); + cmd.arg("add") + .arg("feature") + .arg("test_feature") + .arg("--title") + .arg("Test Feature") + .arg("--description") + .arg("A test feature") + .arg("--target") + .arg(&test_file) + .assert() + .success() + .stdout(predicate::str::contains("Successfully added feature: test_feature")); + + // Verify the feature was added + let content = fs::read_to_string(&test_file).unwrap(); + assert!(content.contains("test_feature")); + assert!(content.contains("Test Feature")); +} + +#[test] +fn test_add_constraint_command() { + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.fdml"); + + // Create a minimal FDML file + fs::write(&test_file, r#" +metadata: + version: "1.3" + author: "Test" + description: "Test file" + +entities: [] +actions: [] +features: [] +constraints: [] +"#).unwrap(); + + // Test adding a constraint + let mut cmd = Command::cargo_bin("fdml").unwrap(); + cmd.arg("add") + .arg("constraint") + .arg("test_constraint") + .arg("--name") + .arg("Test Constraint") + .arg("--condition") + .arg("length(name) > 0") + .arg("--applies-to") + .arg("entity.name") + .arg("--target") + .arg(&test_file) + .assert() + .success() + .stdout(predicate::str::contains("Successfully added constraint: test_constraint")); + + // Verify the constraint was added + let content = fs::read_to_string(&test_file).unwrap(); + assert!(content.contains("test_constraint")); + assert!(content.contains("Test Constraint")); + assert!(content.contains("length(name) > 0")); +} + +#[test] +fn test_add_field_command() { + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.fdml"); + + // Create a FDML file with an entity + fs::write(&test_file, r#" +metadata: + version: "1.3" + author: "Test" + description: "Test file" + +entities: + - id: test_entity + name: "Test Entity" + description: "A test entity" + fields: + - name: id + type: string + required: true + +actions: [] +features: [] +constraints: [] +"#).unwrap(); + + // Test adding a field + let mut cmd = Command::cargo_bin("fdml").unwrap(); + cmd.arg("add") + .arg("field") + .arg("test_entity") + .arg("email") + .arg("--field-type") + .arg("string") + .arg("--required") + .arg("--target") + .arg(&test_file) + .assert() + .success() + .stdout(predicate::str::contains("Successfully added field email to entity test_entity")); + + // Verify the field was added + let content = fs::read_to_string(&test_file).unwrap(); + assert!(content.contains("email")); + assert!(content.contains("string")); +} + +#[test] +fn test_list_entities_command() { + let mut cmd = Command::cargo_bin("fdml").unwrap(); + cmd.arg("list") + .arg("entities") + .arg("--target") + .arg("examples/e-commerce/ecommerce.fdml") + .assert() + .success() + .stdout(predicate::str::contains("Entities (4):")) + .stdout(predicate::str::contains("user - User")) + .stdout(predicate::str::contains("product - Product")); +} + +#[test] +fn test_list_features_command() { + let mut cmd = Command::cargo_bin("fdml").unwrap(); + cmd.arg("list") + .arg("features") + .arg("--target") + .arg("examples/e-commerce/ecommerce.fdml") + .assert() + .success() + .stdout(predicate::str::contains("Features (3):")) + .stdout(predicate::str::contains("user_registration")) + .stdout(predicate::str::contains("product_catalog")); +} + +#[test] +fn test_list_actions_command() { + let mut cmd = Command::cargo_bin("fdml").unwrap(); + cmd.arg("list") + .arg("actions") + .arg("--target") + .arg("examples/e-commerce/ecommerce.fdml") + .assert() + .success() + .stdout(predicate::str::contains("Actions (6):")) + .stdout(predicate::str::contains("create_user")) + .stdout(predicate::str::contains("list_products")); +} + +#[test] +fn test_add_command_error_handling() { + let temp_dir = TempDir::new().unwrap(); + let nonexistent_file = temp_dir.path().join("nonexistent.fdml"); + + // Test adding to a nonexistent file should fail + let mut cmd = Command::cargo_bin("fdml").unwrap(); + cmd.arg("add") + .arg("entity") + .arg("test_entity") + .arg("--name") + .arg("Test Entity") + .arg("--target") + .arg(&nonexistent_file) + .assert() + .failure() + .stderr(predicate::str::contains("does not exist")); +} + +#[test] +fn test_add_field_to_nonexistent_entity() { + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.fdml"); + + // Create a minimal FDML file without the entity + fs::write(&test_file, r#" +metadata: + version: "1.3" + author: "Test" + description: "Test file" + +entities: [] +actions: [] +features: [] +constraints: [] +"#).unwrap(); + + // Test adding a field to a nonexistent entity should fail + let mut cmd = Command::cargo_bin("fdml").unwrap(); + cmd.arg("add") + .arg("field") + .arg("nonexistent_entity") + .arg("test_field") + .arg("--field-type") + .arg("string") + .arg("--target") + .arg(&test_file) + .assert() + .failure() + .stderr(predicate::str::contains("Entity 'nonexistent_entity' not found")); } \ No newline at end of file