1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
use crate::cli::args::MigrateArgs;
use crate::exit_codes;
use anyhow::{Context, Result};
use std::fs;
pub fn cmd_migrate(args: MigrateArgs) -> Result<i32> {
let config_path = args.config;
println!("Migrating configuration: {:?}", config_path);
if !config_path.exists() {
anyhow::bail!("Config file not found: {:?}", config_path);
}
// Read original content for backup later
let content = fs::read_to_string(&config_path)
.with_context(|| format!("failed to read config: {:?}", config_path))?;
// Parse config (strict mode to catch ignored fields like 'policies')
let mut config = assay_core::config::load_config(&config_path, false, true)
.context("failed to load config (strict check failed)")?;
println!(
"Loaded config version: {} (model: {}, suite: {})",
config.version, config.model, config.suite
);
// Use core resolver to inline policies
// We treat the config file's parent as base dir
let base_dir = config_path.parent().unwrap_or(std::path::Path::new("."));
// Check if any legacy policies exist before resolving?
// resolve_policies iterates anyway.
let resolved = assay_core::config::resolve::resolve_policies(config.clone(), base_dir)
.context("failed to resolve policies")?;
// Detect if modification happened by comparing
// Or check if config.version == 0
// We can check if `resolved` differs from `config` fields.
// Hack: serialize both to value and compare? Or just implement eq?
// Or we can rely on `resolve_policies` to return modified flag?
// The current signature returns `Result<EvalConfig>`.
// I can assume if version update is needed, I update it.
// Actually, `resolve_policies` modifies inplace if I pass ownership.
// If I compare resolved vs original, I know.
// Assuming resolve_policies handles all inlining.
// If version is 0, we upgrade to 1.
let mut new_config = resolved;
let mut modified = false;
// Check if we effectively changed anything related to policy inlining.
// If new_config != config (ignoring version change which we haven't done yet).
// Eq implementation might be heavy.
// But verify logic:
// If `resolve_policies` found external files, it inlined them.
// The previous implementation printed "Inlining ...".
// I should probably move the printing to `resolve_policies` or just accept silent operation?
// CLI users like feedback.
// Maybe `resolve_policies` should take a callback or return log?
// Or I just print "Policies resolved." if subsequent check shows fields changed.
// To restore detailed logging, I would need `resolve_policies` to support it.
// For now, let's just proceed.
if new_config.version == 0 {
// We mandate upgrade if we are running migrate command
new_config.version = 1;
modified = true;
}
// Also check if any policy loading happened.
// We can check if any `Expected` changed.
// This is getting complicated to detect "modified" accurately without the loop.
// But `migrate` command implies intention.
// If config was already version 1 and fully inlined, `resolve_policies` returns identical config.
// `modified` would be true only if version changed?
// If version was 1, we don't set modified=true based on version.
// Let's rely on JSON comparison for "effective change".
let old_json = serde_json::to_value(&config)?;
let new_json = serde_json::to_value(&new_config)?;
if old_json != new_json {
modified = true;
}
if args.check {
if modified {
eprintln!(
"Migration required for {:?}. Run 'assay migrate' to update configuration.",
config_path
);
return Ok(exit_codes::CONFIG_ERROR);
} else {
println!("Config {:?} is clean (already migrated).", config_path);
return Ok(exit_codes::OK);
}
}
if !modified {
println!("No legacy policies found. Config is already up to date.");
return Ok(exit_codes::OK);
}
// Bump config version is handled above
config = new_config; // Update local var to migrated one
// config.version = 1; // Already done if needed
let new_yaml = serde_yaml::to_string(&config)?;
if args.dry_run {
println!("\n--- Migrated Config (Dry Run) ---");
println!("{}", new_yaml);
} else {
// Backup
let backup_path = {
let mut p = config_path.clone();
if let Some(ext) = p.extension() {
let mut s = ext.to_os_string();
s.push(".bak");
p.set_extension(s);
} else {
p.set_extension("bak");
}
p
};
fs::write(&backup_path, &content)
.with_context(|| format!("failed to write backup: {:?}", backup_path))?;
println!("Original config backed up to: {:?}", backup_path);
fs::write(&config_path, new_yaml)
.with_context(|| format!("failed to write migrated config: {:?}", config_path))?;
println!("✅ Migration complete! Updated {:?}", config_path);
}
Ok(exit_codes::OK)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_migrate_logic() -> Result<()> {
let dir = tempfile::tempdir()?;
let config_path = dir.path().join("mcp-eval.yaml");
let policy_path = dir.path().join("args.yaml");
std::fs::write(
&policy_path,
r#"
type: object
properties:
foo:
type: string
"#,
)?;
std::fs::write(
&config_path,
r#"
suite: legacy-migration
model: dummy
tests:
- id: t1
input:
prompt: "hi"
expected:
type: args_valid
policy: args.yaml
"#,
)?;
// unused imports removed
let args = MigrateArgs {
check: false,
config: config_path.clone(),
dry_run: false,
};
// We run the cmd logic directly
let code = cmd_migrate(args)?;
assert_eq!(code, 0);
let content = std::fs::read_to_string(&config_path)?;
println!("Migrated content:\n{}", content);
assert!(content.contains("configVersion: 1"));
assert!(!content.contains("policy: args.yaml"));
assert!(content.contains("schema:"));
assert!(content.contains("type: object"));
// Check backup
let mut bak = config_path.clone();
bak.set_extension("yaml.bak"); // Logic in code: ext + ".bak" -> .yaml.bak
// Wait, logic is: let mut s = ext.to_os_string(); s.push(".bak"); p.set_extension(s);
// "yaml" -> "yaml.bak". So "mcp-eval.yaml.bak". Correct.
if !bak.exists() {
// Fallback check if extension replacement happened differently
let bak2 = config_path.with_extension("bak");
if bak2.exists() {
bak = bak2;
}
}
assert!(bak.exists(), "Backup file should exist at {:?}", bak);
Ok(())
}
}