1use serde::Serialize;
13
14use crate::engine::Engine;
15use crate::index::{AsgIndexStore, IndexStore};
16use crate::ledger::{AsgLedgerStore, LedgerStore};
17use crate::schema::{LedgerKind, RoleTag};
18use crate::search_fts::symbol_tier;
19
20#[derive(Debug, Clone, Serialize)]
21pub struct Recipe {
22 pub intent: String,
23 pub query: String,
24 pub actions: Vec<RecipeAction>,
25}
26
27#[derive(Debug, Clone, Serialize)]
28pub struct RecipeAction {
29 pub kind: ActionKind,
30 pub file: String,
31 pub qname: String,
32 pub reason: String,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub command: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub role: Option<String>,
37}
38
39#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
48#[serde(rename_all = "snake_case")]
49pub enum ActionKind {
50 Move,
51 Delete,
52 Run,
53 Gate,
54 KeepAsCovered,
55 Review,
56}
57
58pub fn classify_test_migration(
70 engine: &Engine,
71 index_store: &AsgIndexStore,
72 candidate_qnames: &[String],
73 query: &str,
74) -> Recipe {
75 let ledger_store = AsgLedgerStore::from_engine(engine);
76 let mut actions: Vec<RecipeAction> = Vec::new();
77
78 for qname in candidate_qnames {
79 let sym = match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
80 Ok(Some(s)) => s,
81 _ => continue,
82 };
83 if symbol_tier(&sym.file) != 2 {
84 continue;
85 }
86 let entries = ledger_store
87 .list_entries(&engine.ref_name, &sym.symbol_id)
88 .unwrap_or_default();
89
90 actions.push(pick_action(&entries, sym.file.clone(), sym.qname.clone()));
91 }
92
93 actions.sort_by_key(|a| action_sort_key(a.kind));
94
95 Recipe {
96 intent: "classify-test-migration".to_string(),
97 query: query.to_string(),
98 actions,
99 }
100}
101
102fn pick_action(
103 entries: &[crate::schema::LedgerEntry],
104 file: String,
105 qname: String,
106) -> RecipeAction {
107 for entry in entries {
108 if matches!(entry.kind, LedgerKind::Mapping) {
109 return RecipeAction {
110 kind: ActionKind::KeepAsCovered,
111 file,
112 qname,
113 reason: format!("covered by mapping: {}", entry.summary),
114 command: entry.command.clone(),
115 role: entry.role.clone(),
116 };
117 }
118 }
119 for entry in entries {
120 if !matches!(entry.kind, LedgerKind::Constraint | LedgerKind::Decision) {
121 continue;
122 }
123 if entry.role.as_deref().and_then(RoleTag::from_str) == Some(RoleTag::StaleApi) {
124 return RecipeAction {
125 kind: ActionKind::Delete,
126 file,
127 qname,
128 reason: format!("stale-api per ledger: {}", entry.summary),
129 command: entry.command.clone(),
130 role: entry.role.clone(),
131 };
132 }
133 }
134 for entry in entries {
135 if !matches!(entry.kind, LedgerKind::Ownership | LedgerKind::Concept) {
136 continue;
137 }
138 if entry.role.as_deref().and_then(RoleTag::from_str) == Some(RoleTag::DiagnosticTest) {
139 return RecipeAction {
140 kind: ActionKind::Gate,
141 file,
142 qname,
143 reason: format!("diagnostic-only per classification: {}", entry.summary),
144 command: entry.command.clone(),
145 role: entry.role.clone(),
146 };
147 }
148 }
149 for entry in entries {
150 if !matches!(entry.kind, LedgerKind::Ownership | LedgerKind::Concept) {
151 continue;
152 }
153 if entry.role.as_deref().and_then(RoleTag::from_str) == Some(RoleTag::FastTest)
154 && entry.command.is_some()
155 {
156 return RecipeAction {
157 kind: ActionKind::Run,
158 file,
159 qname,
160 reason: "fast-test with reproduction command".to_string(),
161 command: entry.command.clone(),
162 role: entry.role.clone(),
163 };
164 }
165 }
166 RecipeAction {
167 kind: ActionKind::Review,
168 file,
169 qname,
170 reason: "no role-tagged ledger entry; needs human classification".to_string(),
171 command: None,
172 role: None,
173 }
174}
175
176pub fn migrate_stale_tests(
190 engine: &Engine,
191 index_store: &AsgIndexStore,
192 candidate_qnames: &[String],
193 query: &str,
194) -> Recipe {
195 let ledger_store = AsgLedgerStore::from_engine(engine);
196 let mut actions: Vec<RecipeAction> = Vec::new();
197
198 for qname in candidate_qnames {
199 let sym = match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
200 Ok(Some(s)) => s,
201 _ => continue,
202 };
203 if symbol_tier(&sym.file) != 2 {
204 continue;
205 }
206 let entries = ledger_store
207 .list_entries(&engine.ref_name, &sym.symbol_id)
208 .unwrap_or_default();
209
210 if let Some(move_to) = entries.iter().find_map(|e| {
214 if !matches!(e.kind, LedgerKind::Mapping) {
215 return None;
216 }
217 mapping_move_to(e.body.as_deref())
218 }) {
219 actions.push(RecipeAction {
220 kind: ActionKind::Move,
221 file: sym.file.clone(),
222 qname: sym.qname.clone(),
223 reason: format!("mapping says move to {move_to}"),
224 command: Some(move_to),
225 role: None,
226 });
227 continue;
228 }
229 actions.push(pick_action(&entries, sym.file.clone(), sym.qname.clone()));
232 }
233
234 actions.sort_by_key(|a| action_sort_key(a.kind));
235
236 Recipe {
237 intent: "migrate-stale-tests".to_string(),
238 query: query.to_string(),
239 actions,
240 }
241}
242
243fn mapping_move_to(body: Option<&str>) -> Option<String> {
247 let raw = body?;
248 let v: serde_json::Value = serde_json::from_str(raw).ok()?;
249 v.get("move_to")?.as_str().map(|s| s.to_string())
250}
251
252fn action_sort_key(kind: ActionKind) -> u8 {
253 match kind {
254 ActionKind::Delete => 0,
255 ActionKind::Move => 1,
256 ActionKind::Gate => 2,
257 ActionKind::Run => 3,
258 ActionKind::KeepAsCovered => 4,
259 ActionKind::Review => 5,
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use crate::schema::{Author, AuthorKind, LedgerEntry, Position, Symbol, SymbolKind};
267
268 fn engine_with_symbol(qname: &str, file: &str) -> (Engine, String) {
269 let engine = Engine::open_in_memory().unwrap();
270 let index = AsgIndexStore::from_engine(&engine);
271 let sym = Symbol {
272 symbol_id: format!("sym_{}", qname.replace('.', "_")),
273 symbol_fp: "fp".into(),
274 qname: qname.to_string(),
275 language: "python".into(),
276 kind: SymbolKind::Function,
277 file: file.to_string(),
278 start: Position { line: 1, col: 0 },
279 end: Position { line: 5, col: 0 },
280 signature: None,
281 doc: None,
282 };
283 index.put_symbol(&engine.ref_name, &sym, "test").unwrap();
284 (engine, sym.symbol_id)
285 }
286
287 fn append(
288 engine: &Engine,
289 sym_id: &str,
290 kind: LedgerKind,
291 role: Option<&str>,
292 command: Option<&str>,
293 ) {
294 let ledger = AsgLedgerStore::from_engine(engine);
295 let mut entry = LedgerEntry::new(
296 sym_id,
297 kind,
298 "test entry",
299 Author {
300 kind: AuthorKind::Agent,
301 id: "test".into(),
302 },
303 );
304 entry.role = role.map(str::to_string);
305 entry.command = command.map(str::to_string);
306 ledger
307 .append_entry(&engine.ref_name, &entry, "test")
308 .unwrap();
309 }
310
311 #[test]
312 fn stale_api_constraint_yields_delete() {
313 let (engine, sid) = engine_with_symbol("pkg.tests.legacy_test", "tests/legacy_test.py");
314 append(
315 &engine,
316 &sid,
317 LedgerKind::Constraint,
318 Some("stale-api"),
319 None,
320 );
321 let index = AsgIndexStore::from_engine(&engine);
322 let recipe = classify_test_migration(
323 &engine,
324 &index,
325 &["pkg.tests.legacy_test".to_string()],
326 "migrate stale tests",
327 );
328 assert_eq!(recipe.actions.len(), 1);
329 assert_eq!(recipe.actions[0].kind, ActionKind::Delete);
330 }
331
332 #[test]
333 fn mapping_yields_keep_as_covered() {
334 let (engine, sid) = engine_with_symbol("pkg.tests.covered_test", "tests/covered_test.py");
335 append(&engine, &sid, LedgerKind::Mapping, None, None);
336 let index = AsgIndexStore::from_engine(&engine);
337 let recipe = classify_test_migration(
338 &engine,
339 &index,
340 &["pkg.tests.covered_test".to_string()],
341 "migrate tests",
342 );
343 assert_eq!(recipe.actions[0].kind, ActionKind::KeepAsCovered);
344 }
345
346 #[test]
347 fn diagnostic_classification_yields_gate() {
348 let (engine, sid) =
349 engine_with_symbol("pkg.tests.real_file_test", "tests/real_file_test.py");
350 append(
351 &engine,
352 &sid,
353 LedgerKind::Ownership,
354 Some("diagnostic-test"),
355 None,
356 );
357 let index = AsgIndexStore::from_engine(&engine);
358 let recipe = classify_test_migration(
359 &engine,
360 &index,
361 &["pkg.tests.real_file_test".to_string()],
362 "migrate tests",
363 );
364 assert_eq!(recipe.actions[0].kind, ActionKind::Gate);
365 }
366
367 #[test]
368 fn fast_test_classification_with_command_yields_run() {
369 let (engine, sid) = engine_with_symbol("pkg.tests.fast_test", "tests/fast_test.py");
370 append(
371 &engine,
372 &sid,
373 LedgerKind::Ownership,
374 Some("fast-test"),
375 Some("swift test --filter FastTest"),
376 );
377 let index = AsgIndexStore::from_engine(&engine);
378 let recipe = classify_test_migration(
379 &engine,
380 &index,
381 &["pkg.tests.fast_test".to_string()],
382 "migrate tests",
383 );
384 assert_eq!(recipe.actions[0].kind, ActionKind::Run);
385 assert_eq!(
386 recipe.actions[0].command.as_deref(),
387 Some("swift test --filter FastTest")
388 );
389 }
390
391 #[test]
392 fn no_ledger_evidence_falls_to_review() {
393 let (engine, _) = engine_with_symbol("pkg.tests.bare_test", "tests/bare_test.py");
394 let index = AsgIndexStore::from_engine(&engine);
395 let recipe = classify_test_migration(
396 &engine,
397 &index,
398 &["pkg.tests.bare_test".to_string()],
399 "migrate tests",
400 );
401 assert_eq!(recipe.actions[0].kind, ActionKind::Review);
402 }
403
404 #[test]
405 fn non_test_tier_symbols_are_skipped() {
406 let (engine, sid) = engine_with_symbol("pkg.module.production_fn", "src/pkg/module.py");
407 append(&engine, &sid, LedgerKind::Mapping, None, None);
408 let index = AsgIndexStore::from_engine(&engine);
409 let recipe = classify_test_migration(
410 &engine,
411 &index,
412 &["pkg.module.production_fn".to_string()],
413 "migrate tests",
414 );
415 assert_eq!(
416 recipe.actions.len(),
417 0,
418 "production-tier symbols must not appear in a test-migration recipe"
419 );
420 }
421
422 fn append_mapping_with_body(engine: &Engine, sym_id: &str, body: &str) {
425 let ledger = AsgLedgerStore::from_engine(engine);
426 let mut entry = LedgerEntry::new(
427 sym_id,
428 LedgerKind::Mapping,
429 "mapping with body",
430 Author {
431 kind: AuthorKind::Agent,
432 id: "test".into(),
433 },
434 );
435 entry.body = Some(body.to_string());
436 ledger
437 .append_entry(&engine.ref_name, &entry, "test")
438 .unwrap();
439 }
440
441 #[test]
442 fn mapping_move_to_parses_body_field() {
443 assert_eq!(
444 super::mapping_move_to(Some(r#"{"move_to":"tests/modern/foo.py"}"#)),
445 Some("tests/modern/foo.py".to_string())
446 );
447 }
448
449 #[test]
450 fn mapping_move_to_returns_none_when_missing() {
451 assert_eq!(super::mapping_move_to(None), None);
452 assert_eq!(super::mapping_move_to(Some("plain text")), None);
453 assert_eq!(super::mapping_move_to(Some(r#"{"to_qname":"x"}"#)), None);
454 }
455
456 #[test]
457 fn migrate_stale_tests_emits_move_when_mapping_has_move_to() {
458 let (engine, sid) = engine_with_symbol("pkg.tests.legacy_test", "tests/legacy_test.py");
459 append_mapping_with_body(
460 &engine,
461 &sid,
462 r#"{"from_qname":"pkg.tests.legacy_test","to_qname":"pkg.tests.modern_test","move_to":"tests/modern/foo_test.py"}"#,
463 );
464 let index = AsgIndexStore::from_engine(&engine);
465 let recipe = super::migrate_stale_tests(
466 &engine,
467 &index,
468 &["pkg.tests.legacy_test".to_string()],
469 "migrate stale tests",
470 );
471 assert_eq!(recipe.intent, "migrate-stale-tests");
472 assert_eq!(recipe.actions.len(), 1);
473 assert_eq!(recipe.actions[0].kind, ActionKind::Move);
474 assert_eq!(
475 recipe.actions[0].command.as_deref(),
476 Some("tests/modern/foo_test.py")
477 );
478 }
479
480 #[test]
481 fn migrate_stale_tests_falls_back_to_classify_decision_tree() {
482 let (engine, sid) = engine_with_symbol("pkg.tests.covered_test", "tests/covered_test.py");
485 append_mapping_with_body(
486 &engine,
487 &sid,
488 r#"{"from_qname":"pkg.tests.covered_test","to_qname":"pkg.tests.new"}"#,
489 );
490 let index = AsgIndexStore::from_engine(&engine);
491 let recipe = super::migrate_stale_tests(
492 &engine,
493 &index,
494 &["pkg.tests.covered_test".to_string()],
495 "migrate stale tests",
496 );
497 assert_eq!(recipe.actions[0].kind, ActionKind::KeepAsCovered);
498 }
499
500 #[test]
501 fn migrate_stale_tests_routes_stale_api_constraint_to_delete() {
502 let (engine, sid) = engine_with_symbol("pkg.tests.legacy", "tests/legacy_test.py");
504 append(
505 &engine,
506 &sid,
507 LedgerKind::Constraint,
508 Some("stale-api"),
509 None,
510 );
511 let index = AsgIndexStore::from_engine(&engine);
512 let recipe = super::migrate_stale_tests(
513 &engine,
514 &index,
515 &["pkg.tests.legacy".to_string()],
516 "migrate stale tests",
517 );
518 assert_eq!(recipe.actions[0].kind, ActionKind::Delete);
519 }
520
521 #[test]
522 fn migrate_stale_tests_skips_non_test_tier() {
523 let (engine, sid) = engine_with_symbol("pkg.module.production_fn", "src/pkg/module.py");
524 append_mapping_with_body(&engine, &sid, r#"{"move_to":"src/new.py"}"#);
525 let index = AsgIndexStore::from_engine(&engine);
526 let recipe = super::migrate_stale_tests(
527 &engine,
528 &index,
529 &["pkg.module.production_fn".to_string()],
530 "migrate",
531 );
532 assert_eq!(recipe.actions.len(), 0);
533 }
534}