#![allow(clippy::unwrap_used)]
use std::{collections::HashMap, fmt::Write};
use fraiseql_core::federation::types::{FederatedType, FederationMetadata, KeyDirective};
#[test]
fn test_parse_compose_arguments_single_subgraph() {
let args = vec![
"fraiseql".to_string(),
"compose".to_string(),
"--subgraph".to_string(),
"users:users.json".to_string(),
];
let result = parse_compose_args(&args);
assert!(result.is_ok(), "Should parse single subgraph argument");
let parsed = result.unwrap();
assert_eq!(parsed.subgraphs.len(), 1);
assert_eq!(parsed.subgraphs[0].name, "users");
assert_eq!(parsed.subgraphs[0].path, "users.json");
}
#[test]
fn test_parse_compose_arguments_multiple_subgraphs() {
let args = vec![
"fraiseql".to_string(),
"compose".to_string(),
"--subgraph".to_string(),
"users:users.json".to_string(),
"--subgraph".to_string(),
"orders:orders.json".to_string(),
];
let parsed = parse_compose_args(&args).expect("should parse multiple subgraph arguments");
assert_eq!(parsed.subgraphs.len(), 2);
assert_eq!(parsed.subgraphs[0].name, "users");
assert_eq!(parsed.subgraphs[1].name, "orders");
}
#[test]
fn test_parse_compose_arguments_with_output() {
let args = vec![
"fraiseql".to_string(),
"compose".to_string(),
"--subgraph".to_string(),
"users:users.json".to_string(),
"--output".to_string(),
"supergraph.json".to_string(),
];
let parsed = parse_compose_args(&args).expect("should parse output argument");
assert_eq!(parsed.output_path, Some("supergraph.json".to_string()));
}
#[test]
fn test_parse_compose_arguments_invalid_subgraph_format() {
let args = vec![
"fraiseql".to_string(),
"compose".to_string(),
"--subgraph".to_string(),
"users_no_colon".to_string(),
];
let result = parse_compose_args(&args);
assert!(result.is_err(), "Should reject subgraph argument without colon");
let err = result.unwrap_err();
assert!(
err.to_lowercase().contains("format") || err.to_lowercase().contains("subgraph"),
"Error should mention format or subgraph: {}",
err
);
}
#[test]
fn test_parse_compose_arguments_missing_subgraphs() {
let args = vec!["fraiseql".to_string(), "compose".to_string()];
let result = parse_compose_args(&args);
assert!(result.is_err(), "Should require at least one subgraph");
let err = result.unwrap_err();
assert!(
err.to_lowercase().contains("subgraph") || err.to_lowercase().contains("required"),
"Error should mention subgraph requirement: {}",
err
);
}
#[test]
fn test_load_compose_configuration_default() {
let result = load_compose_config(None);
assert!(result.is_ok(), "Should load default configuration");
let config = result.unwrap();
assert_eq!(config.conflict_resolution, "error", "Default should be error on conflict");
}
#[test]
fn test_load_compose_configuration_from_file() {
let config_content = r"
composition:
conflict_resolution: shareable
validation: true
";
let result = parse_config_yaml(config_content);
assert!(result.is_ok(), "Should parse YAML configuration");
let config = result.unwrap();
assert_eq!(config.conflict_resolution, "shareable");
}
#[test]
fn test_resolve_conflict_strategy_error() {
let config = ComposeConfig {
conflict_resolution: "error".to_string(),
validation: true,
subgraph_priority: vec![],
};
let conflict = FieldTypeConflict {
field_name: "email".to_string(),
type_name: "User".to_string(),
subgraph1: ("users".to_string(), "String".to_string()),
subgraph2: ("auth".to_string(), "Int".to_string()),
};
let result = resolve_conflict(&conflict, &config);
assert!(result.is_err(), "Error strategy should reject conflicts");
}
#[test]
fn test_resolve_conflict_strategy_first_wins() {
let config = ComposeConfig {
conflict_resolution: "first_wins".to_string(),
validation: true,
subgraph_priority: vec!["users".to_string(), "auth".to_string()],
};
let conflict = FieldTypeConflict {
field_name: "email".to_string(),
type_name: "User".to_string(),
subgraph1: ("users".to_string(), "String".to_string()),
subgraph2: ("auth".to_string(), "Int".to_string()),
};
let result = resolve_conflict(&conflict, &config);
assert!(result.is_ok(), "first_wins strategy should accept");
let resolution = result.unwrap();
assert_eq!(resolution.chosen_type, "String", "Should choose first type");
assert_eq!(resolution.chosen_subgraph, "users");
}
#[test]
fn test_resolve_conflict_strategy_shareable() {
let config = ComposeConfig {
conflict_resolution: "shareable".to_string(),
validation: true,
subgraph_priority: vec![],
};
let conflict = FieldTypeConflict {
field_name: "email".to_string(),
type_name: "User".to_string(),
subgraph1: ("users".to_string(), "String".to_string()),
subgraph2: ("auth".to_string(), "Int".to_string()),
};
let _result = resolve_conflict(&conflict, &config);
}
#[test]
fn test_compose_workflow_basic() {
let users_schema = FederationMetadata {
enabled: true,
version: "v2".to_string(),
types: vec![{
let mut t = FederatedType::new("User".to_string());
t.keys.push(KeyDirective {
fields: vec!["id".to_string()],
resolvable: true,
});
t
}],
};
let orders_schema = FederationMetadata {
enabled: true,
version: "v2".to_string(),
types: vec![{
let mut t = FederatedType::new("Order".to_string());
t.keys.push(KeyDirective {
fields: vec!["id".to_string()],
resolvable: true,
});
t
}],
};
let subgraphs = vec![
SubgraphInput {
name: "users".to_string(),
schema: users_schema,
},
SubgraphInput {
name: "orders".to_string(),
schema: orders_schema,
},
];
let config = ComposeConfig {
conflict_resolution: "error".to_string(),
validation: true,
subgraph_priority: vec![],
};
let result = execute_compose_workflow(&subgraphs, &config);
assert!(result.is_ok(), "Should complete compose workflow");
let composed = result.unwrap();
assert_eq!(composed.types.len(), 2, "Should have both types");
}
#[test]
fn test_compose_workflow_with_validation_errors() {
let invalid_schema = FederationMetadata {
enabled: true,
version: "v2".to_string(),
types: vec![],
};
let subgraphs = vec![SubgraphInput {
name: "users".to_string(),
schema: invalid_schema,
}];
let config = ComposeConfig {
conflict_resolution: "error".to_string(),
validation: true,
subgraph_priority: vec![],
};
let _result = execute_compose_workflow(&subgraphs, &config);
}
#[test]
fn test_error_message_missing_subgraph_file() {
let err = format_error(
"SubgraphFileNotFound",
&HashMap::from([
("subgraph".to_string(), "users".to_string()),
("path".to_string(), "nonexistent.json".to_string()),
]),
);
assert!(err.to_lowercase().contains("not found"));
assert!(err.to_lowercase().contains("users"));
assert!(err.to_lowercase().contains("nonexistent.json"));
}
#[test]
fn test_error_message_composition_conflict() {
let err = format_error(
"CompositionConflict",
&HashMap::from([
("type".to_string(), "User".to_string()),
("field".to_string(), "email".to_string()),
("subgraph1".to_string(), "users".to_string()),
("type1".to_string(), "String".to_string()),
("subgraph2".to_string(), "auth".to_string()),
("type2".to_string(), "Int".to_string()),
]),
);
assert!(err.to_lowercase().contains("conflict"));
assert!(err.to_lowercase().contains("user") || err.to_lowercase().contains("email"));
}
#[derive(Debug, Clone)]
struct ComposeArgs {
pub subgraphs: Vec<SubgraphArg>,
pub output_path: Option<String>,
#[allow(dead_code)] pub config_path: Option<String>,
}
#[derive(Debug, Clone)]
struct SubgraphArg {
pub name: String,
pub path: String,
}
#[derive(Debug, Clone)]
struct ComposeConfig {
pub conflict_resolution: String, pub validation: bool,
pub subgraph_priority: Vec<String>,
}
#[derive(Debug, Clone)]
struct FieldTypeConflict {
pub field_name: String,
pub type_name: String,
pub subgraph1: (String, String), pub subgraph2: (String, String), }
#[derive(Debug, Clone)]
struct ConflictResolution {
pub chosen_type: String,
pub chosen_subgraph: String,
}
#[derive(Debug, Clone)]
struct SubgraphInput {
pub name: String,
pub schema: FederationMetadata,
}
fn parse_compose_args(args: &[String]) -> Result<ComposeArgs, String> {
if args.len() < 2 {
return Err("Usage: fraiseql compose --subgraph NAME:PATH [--output PATH]".to_string());
}
let mut subgraphs = Vec::new();
let mut output_path = None;
let mut config_path = None;
let mut i = 2; while i < args.len() {
match args[i].as_str() {
"--subgraph" => {
if i + 1 >= args.len() {
return Err("--subgraph requires NAME:PATH argument".to_string());
}
i += 1;
let subgraph_arg = &args[i];
let parts: Vec<&str> = subgraph_arg.split(':').collect();
if parts.len() != 2 {
return Err(format!(
"Invalid subgraph format '{}'. Expected NAME:PATH",
subgraph_arg
));
}
subgraphs.push(SubgraphArg {
name: parts[0].to_string(),
path: parts[1].to_string(),
});
},
"--output" => {
if i + 1 >= args.len() {
return Err("--output requires PATH argument".to_string());
}
i += 1;
output_path = Some(args[i].clone());
},
"--config" => {
if i + 1 >= args.len() {
return Err("--config requires PATH argument".to_string());
}
i += 1;
config_path = Some(args[i].clone());
},
_ => return Err(format!("Unknown argument: {}", args[i])),
}
i += 1;
}
if subgraphs.is_empty() {
return Err("At least one --subgraph argument is required".to_string());
}
Ok(ComposeArgs {
subgraphs,
output_path: output_path.or_else(|| Some("supergraph.json".to_string())),
config_path,
})
}
fn load_compose_config(config_path: Option<&str>) -> Result<ComposeConfig, String> {
let default_config = ComposeConfig {
conflict_resolution: "error".to_string(),
validation: true,
subgraph_priority: vec![],
};
if let Some(_path) = config_path {
}
Ok(default_config)
}
#[allow(dead_code)] fn parse_config_yaml(content: &str) -> Result<ComposeConfig, String> {
let conflict_resolution = if content.contains("shareable") {
"shareable".to_string()
} else {
"error".to_string()
};
Ok(ComposeConfig {
conflict_resolution,
validation: true,
subgraph_priority: vec![],
})
}
fn resolve_conflict(
conflict: &FieldTypeConflict,
config: &ComposeConfig,
) -> Result<ConflictResolution, String> {
match config.conflict_resolution.as_str() {
"error" => Err(format!(
"Composition Error: Field type conflict\n\
Type: {}\n\
Field: {}\n\
Conflict: {} defines {} as {}, but {} defines it as {}\n\
Suggestion: Use conflict_resolution strategy in fraiseql.yml",
conflict.type_name,
conflict.field_name,
conflict.subgraph1.0,
conflict.field_name,
conflict.subgraph1.1,
conflict.subgraph2.0,
conflict.subgraph2.1,
)),
"first_wins" => {
let priority = config.subgraph_priority.clone();
let first_idx = priority.iter().position(|s| s == &conflict.subgraph1.0);
let second_idx = priority.iter().position(|s| s == &conflict.subgraph2.0);
let chosen = match (first_idx, second_idx) {
(Some(a), Some(b)) if a < b => conflict.subgraph1.clone(),
_ => conflict.subgraph1.clone(),
};
Ok(ConflictResolution {
chosen_type: chosen.1,
chosen_subgraph: chosen.0,
})
},
"shareable" => {
Ok(ConflictResolution {
chosen_type: conflict.subgraph1.1.clone(),
chosen_subgraph: conflict.subgraph1.0.clone(),
})
},
_ => Err(format!("Unknown conflict resolution strategy: {}", config.conflict_resolution)),
}
}
fn execute_compose_workflow(
subgraphs: &[SubgraphInput],
config: &ComposeConfig,
) -> Result<FederationMetadata, String> {
if subgraphs.is_empty() {
return Err("No subgraphs provided for composition".to_string());
}
if config.validation {
for subgraph in subgraphs {
if !subgraph.schema.enabled {
return Err(format!("Subgraph '{}' has federation disabled", subgraph.name));
}
}
}
let metadata_list: Vec<_> = subgraphs.iter().map(|s| s.schema.clone()).collect();
compose_federation_schemas(&metadata_list)
}
fn compose_federation_schemas(
subgraphs: &[FederationMetadata],
) -> Result<FederationMetadata, String> {
if subgraphs.is_empty() {
return Ok(FederationMetadata {
enabled: false,
version: "v2".to_string(),
types: vec![],
});
}
let mut types_by_name: HashMap<String, FederatedType> = HashMap::new();
for subgraph in subgraphs {
for type_def in &subgraph.types {
types_by_name.entry(type_def.name.clone()).or_insert_with(|| type_def.clone());
}
}
let composed_types: Vec<_> = types_by_name.into_values().collect();
Ok(FederationMetadata {
enabled: subgraphs.iter().any(|s| s.enabled),
version: subgraphs.first().map_or_else(|| "v2".to_string(), |s| s.version.clone()),
types: composed_types,
})
}
fn format_error(error_type: &str, context: &HashMap<String, String>) -> String {
let base_msg = match error_type {
"SubgraphFileNotFound" => "Subgraph file not found",
"CompositionConflict" => "Composition conflict detected",
_ => error_type,
};
let mut msg = format!("Error: {}\n", base_msg);
for (key, value) in context {
let _ = writeln!(msg, "{}: {}", key, value);
}
msg
}