1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicBool, Ordering};
10
11use colored::Colorize;
12
13use super::loader::{
14 DiscoveryOptions, ExtensionError, ExtensionManifest, ExtensionRegistry, LegacyAgentToml,
15};
16
17static DEPRECATION_WARNING_SHOWN: AtomicBool = AtomicBool::new(false);
20
21#[derive(Debug, Clone)]
23pub struct DeprecationConfig {
24 pub enabled: bool,
26
27 pub once_per_session: bool,
29
30 pub show_migration_hints: bool,
32}
33
34impl Default for DeprecationConfig {
35 fn default() -> Self {
36 Self {
37 enabled: true,
38 once_per_session: true,
39 show_migration_hints: true,
40 }
41 }
42}
43
44impl DeprecationConfig {
45 pub fn silent() -> Self {
47 Self {
48 enabled: false,
49 once_per_session: false,
50 show_migration_hints: false,
51 }
52 }
53}
54
55#[derive(Debug)]
62pub struct MigrationShim {
63 deprecation_config: DeprecationConfig,
65
66 agent_type_cache: HashMap<String, ExtensionManifest>,
68
69 legacy_paths_loaded: Vec<PathBuf>,
71}
72
73impl MigrationShim {
74 pub fn new() -> Self {
76 Self {
77 deprecation_config: DeprecationConfig::default(),
78 agent_type_cache: HashMap::new(),
79 legacy_paths_loaded: Vec::new(),
80 }
81 }
82
83 pub fn with_config(config: DeprecationConfig) -> Self {
85 Self {
86 deprecation_config: config,
87 agent_type_cache: HashMap::new(),
88 legacy_paths_loaded: Vec::new(),
89 }
90 }
91
92 pub fn load_legacy_agent(
94 &mut self,
95 path: &Path,
96 ) -> Result<ExtensionManifest, ExtensionError> {
97 let content = std::fs::read_to_string(path)
98 .map_err(|e| ExtensionError::Io(format!("Failed to read {}: {}", path.display(), e)))?;
99
100 self.load_legacy_agent_str(&content, &path.to_path_buf())
101 }
102
103 pub fn load_legacy_agent_str(
105 &mut self,
106 content: &str,
107 path: &PathBuf,
108 ) -> Result<ExtensionManifest, ExtensionError> {
109 let legacy: LegacyAgentToml = toml::from_str(content)
110 .map_err(|e| ExtensionError::Parse(format!("Failed to parse legacy format: {}", e)))?;
111
112 let agent_name = legacy.agent.name.clone();
114
115 self.emit_deprecation_warning(path, &agent_name);
117
118 self.legacy_paths_loaded.push(path.clone());
120
121 let manifest = legacy.into_extension_manifest(path);
123
124 self.agent_type_cache
126 .insert(agent_name, manifest.clone());
127
128 Ok(manifest)
129 }
130
131 pub fn resolve_agent_type(
140 &mut self,
141 agent_type: &str,
142 project_root: &Path,
143 ) -> Option<ExtensionManifest> {
144 if let Some(manifest) = self.agent_type_cache.get(agent_type) {
146 return Some(manifest.clone());
147 }
148
149 let local_path = project_root
151 .join(".scud")
152 .join("agents")
153 .join(format!("{}.toml", agent_type));
154
155 if local_path.exists() {
156 if let Ok(manifest) = self.load_legacy_agent(&local_path) {
157 return Some(manifest);
158 }
159 }
160
161 if let Some(manifest) = self.load_builtin_agent(agent_type) {
163 return Some(manifest);
164 }
165
166 None
167 }
168
169 fn load_builtin_agent(&mut self, name: &str) -> Option<ExtensionManifest> {
171 let content = match name {
173 "builder" => Some(include_str!("../assets/spawn-agents/builder.toml")),
174 "analyzer" => Some(include_str!("../assets/spawn-agents/analyzer.toml")),
175 "planner" => Some(include_str!("../assets/spawn-agents/planner.toml")),
176 "researcher" => Some(include_str!("../assets/spawn-agents/researcher.toml")),
177 "reviewer" => Some(include_str!("../assets/spawn-agents/reviewer.toml")),
178 "repairer" => Some(include_str!("../assets/spawn-agents/repairer.toml")),
179 "fast-builder" => Some(include_str!("../assets/spawn-agents/fast-builder.toml")),
180 "outside-generalist" => {
181 Some(include_str!("../assets/spawn-agents/outside-generalist.toml"))
182 }
183 _ => None,
184 }?;
185
186 let path = PathBuf::from(format!("built-in:{}.toml", name));
187
188 let legacy: LegacyAgentToml = toml::from_str(content).ok()?;
190 let manifest = legacy.into_extension_manifest(&path);
191
192 self.agent_type_cache.insert(name.to_string(), manifest.clone());
194
195 Some(manifest)
196 }
197
198 fn emit_deprecation_warning(&self, path: &Path, agent_name: &str) {
200 if !self.deprecation_config.enabled {
201 return;
202 }
203
204 if self.deprecation_config.once_per_session {
205 if DEPRECATION_WARNING_SHOWN.swap(true, Ordering::SeqCst) {
207 return;
208 }
209 }
210
211 eprintln!(
212 "{} {} {}",
213 "⚠".yellow(),
214 "Deprecation warning:".yellow().bold(),
215 "Legacy agent TOML format detected".yellow()
216 );
217 eprintln!(
218 " {} {} ({})",
219 "→".dimmed(),
220 agent_name.cyan(),
221 path.display().to_string().dimmed()
222 );
223
224 if self.deprecation_config.show_migration_hints {
225 eprintln!();
226 eprintln!(
227 " {} The legacy [agent]/[model]/[prompt] format is deprecated.",
228 "ℹ".blue()
229 );
230 eprintln!(
231 " {} Use the new extension format with [extension] section instead.",
232 "ℹ".blue()
233 );
234 eprintln!();
235 eprintln!(" {} Convert legacy format:", "→".dimmed());
236 eprintln!(" {}", "scud migrate-agents".cyan());
237 eprintln!();
238 eprintln!(
239 " {} To suppress this warning, set SCUD_NO_DEPRECATION_WARNINGS=1",
240 "→".dimmed()
241 );
242 }
243 }
244
245 pub fn legacy_paths(&self) -> &[PathBuf] {
247 &self.legacy_paths_loaded
248 }
249
250 pub fn has_legacy_loads(&self) -> bool {
252 !self.legacy_paths_loaded.is_empty()
253 }
254
255 pub fn cached_agent_types(&self) -> Vec<&str> {
257 self.agent_type_cache.keys().map(|s| s.as_str()).collect()
258 }
259
260 pub fn clear_cache(&mut self) {
262 self.agent_type_cache.clear();
263 self.legacy_paths_loaded.clear();
264 }
265}
266
267impl Default for MigrationShim {
268 fn default() -> Self {
269 Self::new()
270 }
271}
272
273pub fn is_legacy_agent_format(content: &str) -> bool {
275 toml::from_str::<LegacyAgentToml>(content).is_ok()
277 && !content.contains("[extension]")
278}
279
280pub fn is_legacy_agent_file(path: &Path) -> Result<bool, ExtensionError> {
282 let content = std::fs::read_to_string(path)
283 .map_err(|e| ExtensionError::Io(format!("Failed to read {}: {}", path.display(), e)))?;
284 Ok(is_legacy_agent_format(&content))
285}
286
287pub fn convert_legacy_to_extension_toml(
291 legacy_content: &str,
292 source_path: &Path,
293) -> Result<String, ExtensionError> {
294 let legacy: LegacyAgentToml = toml::from_str(legacy_content)
295 .map_err(|e| ExtensionError::Parse(format!("Failed to parse legacy format: {}", e)))?;
296
297 let manifest = legacy.into_extension_manifest(&source_path.to_path_buf());
298
299 let mut output = String::new();
301
302 output.push_str("# Migrated from legacy agent format\n");
304 output.push_str("[extension]\n");
305 output.push_str(&format!("id = \"{}\"\n", manifest.extension.id));
306 output.push_str(&format!("name = \"{}\"\n", manifest.extension.name));
307 output.push_str(&format!("version = \"{}\"\n", manifest.extension.version));
308 output.push_str(&format!(
309 "description = \"\"\"\n{}\n\"\"\"\n",
310 manifest.extension.description
311 ));
312
313 if let Some(ref main) = manifest.extension.main {
314 output.push_str(&format!("main = \"{}\"\n", main));
315 }
316
317 if !manifest.config.is_empty() {
319 output.push_str("\n# Configuration (migrated from legacy [model] and [prompt] sections)\n");
320 output.push_str("[config]\n");
321
322 let mut keys: Vec<_> = manifest.config.keys().collect();
324 keys.sort();
325
326 for key in keys {
327 if let Some(value) = manifest.config.get(key) {
328 match value {
329 serde_json::Value::String(s) => {
330 if s.contains('\n') {
331 output.push_str(&format!("{} = \"\"\"\n{}\n\"\"\"\n", key, s));
332 } else {
333 output.push_str(&format!("{} = \"{}\"\n", key, s));
334 }
335 }
336 serde_json::Value::Bool(b) => {
337 output.push_str(&format!("{} = {}\n", key, b));
338 }
339 serde_json::Value::Number(n) => {
340 output.push_str(&format!("{} = {}\n", key, n));
341 }
342 _ => {
343 if let Ok(toml_value) = serde_json::from_value::<toml::Value>(value.clone())
345 {
346 output.push_str(&format!("{} = {}\n", key, toml_value));
347 }
348 }
349 }
350 }
351 }
352 }
353
354 Ok(output)
355}
356
357pub fn load_registry_with_migration(
359 registry: &mut ExtensionRegistry,
360 root: &Path,
361 options: DiscoveryOptions,
362 deprecation_config: DeprecationConfig,
363) -> Result<MigrationStats, ExtensionError> {
364 let result = super::loader::discover(root, options)?;
365
366 let mut stats = MigrationStats::default();
367
368 for ext in &result.extensions {
369 if ext.is_legacy {
370 stats.legacy_count += 1;
371 stats.legacy_paths.push(ext.path.clone());
372 } else {
373 stats.modern_count += 1;
374 }
375 }
376
377 if stats.legacy_count > 0 && deprecation_config.enabled {
379 emit_summary_deprecation_warning(&stats, &deprecation_config);
380 }
381
382 registry.load_from_discovery(result);
383
384 Ok(stats)
385}
386
387#[derive(Debug, Default)]
389pub struct MigrationStats {
390 pub legacy_count: usize,
392
393 pub modern_count: usize,
395
396 pub legacy_paths: Vec<PathBuf>,
398}
399
400impl MigrationStats {
401 pub fn has_legacy(&self) -> bool {
403 self.legacy_count > 0
404 }
405
406 pub fn total(&self) -> usize {
408 self.legacy_count + self.modern_count
409 }
410}
411
412fn emit_summary_deprecation_warning(stats: &MigrationStats, config: &DeprecationConfig) {
414 if !config.enabled {
415 return;
416 }
417
418 if config.once_per_session && DEPRECATION_WARNING_SHOWN.swap(true, Ordering::SeqCst) {
419 return;
420 }
421
422 eprintln!(
423 "{} {} {} legacy agent definition(s) detected",
424 "⚠".yellow(),
425 "Deprecation warning:".yellow().bold(),
426 stats.legacy_count
427 );
428
429 let max_show = 3;
431 for (i, path) in stats.legacy_paths.iter().take(max_show).enumerate() {
432 eprintln!(" {} {}", "→".dimmed(), path.display().to_string().dimmed());
433 if i == max_show - 1 && stats.legacy_count > max_show {
434 eprintln!(
435 " {} ... and {} more",
436 "→".dimmed(),
437 stats.legacy_count - max_show
438 );
439 }
440 }
441
442 if config.show_migration_hints {
443 eprintln!();
444 eprintln!(
445 " {} Run {} to migrate to the new extension format.",
446 "ℹ".blue(),
447 "scud migrate-agents".cyan()
448 );
449 }
450}
451
452pub fn reset_deprecation_warning_flag() {
454 DEPRECATION_WARNING_SHOWN.store(false, Ordering::SeqCst);
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use tempfile::TempDir;
461
462 fn setup() {
464 reset_deprecation_warning_flag();
465 }
466
467 #[test]
468 fn test_migration_shim_new() {
469 setup();
470 let shim = MigrationShim::new();
471 assert!(!shim.has_legacy_loads());
472 assert!(shim.cached_agent_types().is_empty());
473 }
474
475 #[test]
476 fn test_migration_shim_load_legacy() {
477 setup();
478 let temp = TempDir::new().unwrap();
479 let agent_path = temp.path().join("test-agent.toml");
480
481 let content = r#"
482[agent]
483name = "test-agent"
484description = "A test agent"
485
486[model]
487harness = "claude"
488model = "opus"
489"#;
490 std::fs::write(&agent_path, content).unwrap();
491
492 let mut shim = MigrationShim::with_config(DeprecationConfig::silent());
493 let manifest = shim.load_legacy_agent(&agent_path).unwrap();
494
495 assert_eq!(manifest.extension.name, "test-agent");
496 assert_eq!(manifest.extension.id, "legacy.agent.test-agent");
497 assert!(shim.has_legacy_loads());
498 assert_eq!(shim.legacy_paths().len(), 1);
499 }
500
501 #[test]
502 fn test_resolve_agent_type_from_cache() {
503 setup();
504 let temp = TempDir::new().unwrap();
505 let agents_dir = temp.path().join(".scud").join("agents");
506 std::fs::create_dir_all(&agents_dir).unwrap();
507
508 let agent_path = agents_dir.join("my-agent.toml");
509 let content = r#"
510[agent]
511name = "my-agent"
512description = "My custom agent"
513
514[model]
515harness = "opencode"
516"#;
517 std::fs::write(&agent_path, content).unwrap();
518
519 let mut shim = MigrationShim::with_config(DeprecationConfig::silent());
520
521 let manifest = shim.resolve_agent_type("my-agent", temp.path());
523 assert!(manifest.is_some());
524 assert_eq!(manifest.as_ref().unwrap().extension.name, "my-agent");
525
526 let cached = shim.resolve_agent_type("my-agent", temp.path());
528 assert!(cached.is_some());
529 assert_eq!(cached.unwrap().extension.name, "my-agent");
530
531 assert!(shim.cached_agent_types().contains(&"my-agent"));
533 }
534
535 #[test]
536 fn test_resolve_builtin_agent() {
537 setup();
538 let temp = TempDir::new().unwrap();
539 let mut shim = MigrationShim::with_config(DeprecationConfig::silent());
540
541 let manifest = shim.resolve_agent_type("builder", temp.path());
543 assert!(manifest.is_some());
544 assert_eq!(manifest.as_ref().unwrap().extension.name, "builder");
545 }
546
547 #[test]
548 fn test_resolve_nonexistent_agent() {
549 setup();
550 let temp = TempDir::new().unwrap();
551 let mut shim = MigrationShim::with_config(DeprecationConfig::silent());
552
553 let manifest = shim.resolve_agent_type("nonexistent-agent", temp.path());
554 assert!(manifest.is_none());
555 }
556
557 #[test]
558 fn test_is_legacy_agent_format() {
559 let legacy = r#"
560[agent]
561name = "test"
562description = "test"
563"#;
564 assert!(is_legacy_agent_format(legacy));
565
566 let modern = r#"
567[extension]
568id = "test"
569name = "Test"
570version = "1.0.0"
571description = "test"
572"#;
573 assert!(!is_legacy_agent_format(modern));
574
575 let invalid = r#"
576[random]
577key = "value"
578"#;
579 assert!(!is_legacy_agent_format(invalid));
580 }
581
582 #[test]
583 fn test_convert_legacy_to_extension_toml() {
584 let legacy = r#"
585[agent]
586name = "my-builder"
587description = "A custom builder agent"
588
589[model]
590harness = "claude"
591model = "sonnet"
592
593[prompt]
594template = "You are a builder."
595"#;
596
597 let converted =
598 convert_legacy_to_extension_toml(legacy, Path::new("my-builder.toml")).unwrap();
599
600 assert!(converted.contains("[extension]"));
601 assert!(converted.contains("id = \"legacy.agent.my-builder\""));
602 assert!(converted.contains("name = \"my-builder\""));
603 assert!(converted.contains("[config]"));
604 assert!(converted.contains("harness = \"claude\""));
605 assert!(converted.contains("model = \"sonnet\""));
606 }
607
608 #[test]
609 fn test_migration_stats() {
610 let mut stats = MigrationStats::default();
611 assert!(!stats.has_legacy());
612 assert_eq!(stats.total(), 0);
613
614 stats.legacy_count = 2;
615 stats.modern_count = 3;
616 assert!(stats.has_legacy());
617 assert_eq!(stats.total(), 5);
618 }
619
620 #[test]
621 fn test_deprecation_config_silent() {
622 let config = DeprecationConfig::silent();
623 assert!(!config.enabled);
624 assert!(!config.show_migration_hints);
625 }
626
627 #[test]
628 fn test_clear_cache() {
629 setup();
630 let temp = TempDir::new().unwrap();
631 let mut shim = MigrationShim::with_config(DeprecationConfig::silent());
632
633 shim.resolve_agent_type("builder", temp.path());
635 assert!(!shim.cached_agent_types().is_empty());
636
637 shim.clear_cache();
638 assert!(shim.cached_agent_types().is_empty());
639 assert!(!shim.has_legacy_loads());
640 }
641
642 #[test]
643 fn test_is_legacy_agent_file() {
644 let temp = TempDir::new().unwrap();
645
646 let legacy_path = temp.path().join("legacy.toml");
648 std::fs::write(
649 &legacy_path,
650 r#"
651[agent]
652name = "test"
653description = "test"
654"#,
655 )
656 .unwrap();
657 assert!(is_legacy_agent_file(&legacy_path).unwrap());
658
659 let modern_path = temp.path().join("modern.toml");
661 std::fs::write(
662 &modern_path,
663 r#"
664[extension]
665id = "test"
666name = "Test"
667version = "1.0.0"
668description = "test"
669"#,
670 )
671 .unwrap();
672 assert!(!is_legacy_agent_file(&modern_path).unwrap());
673
674 let missing_path = temp.path().join("missing.toml");
676 assert!(is_legacy_agent_file(&missing_path).is_err());
677 }
678
679 #[test]
680 fn test_load_legacy_agent_str() {
681 setup();
682 let content = r#"
683[agent]
684name = "inline-agent"
685description = "Loaded from string"
686
687[model]
688harness = "opencode"
689model = "grok"
690"#;
691
692 let mut shim = MigrationShim::with_config(DeprecationConfig::silent());
693 let path = PathBuf::from("inline.toml");
694 let manifest = shim.load_legacy_agent_str(content, &path).unwrap();
695
696 assert_eq!(manifest.extension.name, "inline-agent");
697 assert_eq!(
698 manifest.config.get("harness"),
699 Some(&serde_json::Value::String("opencode".to_string()))
700 );
701 }
702
703 #[test]
704 fn test_project_local_overrides_builtin() {
705 setup();
706 let temp = TempDir::new().unwrap();
707 let agents_dir = temp.path().join(".scud").join("agents");
708 std::fs::create_dir_all(&agents_dir).unwrap();
709
710 let agent_path = agents_dir.join("builder.toml");
712 let content = r#"
713[agent]
714name = "builder"
715description = "Custom project builder"
716
717[model]
718harness = "opencode"
719model = "custom-model"
720"#;
721 std::fs::write(&agent_path, content).unwrap();
722
723 let mut shim = MigrationShim::with_config(DeprecationConfig::silent());
724 let manifest = shim.resolve_agent_type("builder", temp.path());
725
726 assert!(manifest.is_some());
727 assert_eq!(manifest.as_ref().unwrap().extension.description, "Custom project builder");
728 assert_eq!(
730 manifest.as_ref().unwrap().config.get("model"),
731 Some(&serde_json::Value::String("custom-model".to_string()))
732 );
733 }
734}