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