Skip to main content

scud/extensions/
migration.rs

1//! Migration shim for legacy TOML agent definitions
2//!
3//! Provides compatibility layer for transitioning from legacy agent TOML format
4//! to the new extension manifest system. Emits deprecation warnings and maps
5//! legacy agent_type fields to extension manifests.
6
7use 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
17/// Global flag to track whether deprecation warnings have been shown this session.
18/// Used to avoid spamming the user with repeated warnings.
19static DEPRECATION_WARNING_SHOWN: AtomicBool = AtomicBool::new(false);
20
21/// Deprecation warning configuration
22#[derive(Debug, Clone)]
23pub struct DeprecationConfig {
24    /// Whether to show deprecation warnings at all
25    pub enabled: bool,
26
27    /// Whether to show only once per session (vs. every load)
28    pub once_per_session: bool,
29
30    /// Whether to suggest migration commands
31    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    /// Create config that suppresses all warnings (useful for tests)
46    pub fn silent() -> Self {
47        Self {
48            enabled: false,
49            once_per_session: false,
50            show_migration_hints: false,
51        }
52    }
53}
54
55/// Migration shim for converting legacy agent formats to extensions
56///
57/// This struct provides utilities for:
58/// - Loading legacy agent TOML files with deprecation warnings
59/// - Mapping task agent_type fields to extension manifests
60/// - Converting between legacy and new formats
61#[derive(Debug)]
62pub struct MigrationShim {
63    /// Configuration for deprecation warnings
64    deprecation_config: DeprecationConfig,
65
66    /// Cache of agent_type -> extension manifest mappings
67    agent_type_cache: HashMap<String, ExtensionManifest>,
68
69    /// Paths that have been loaded as legacy (for tracking)
70    legacy_paths_loaded: Vec<PathBuf>,
71}
72
73impl MigrationShim {
74    /// Create a new migration shim with default configuration
75    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    /// Create a migration shim with custom deprecation config
84    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    /// Load a legacy agent TOML file, emitting deprecation warning if configured
93    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    /// Load a legacy agent from string content
101    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        // Extract name before consuming legacy struct
110        let agent_name = legacy.agent.name.clone();
111
112        // Emit deprecation warning
113        self.emit_deprecation_warning(path, &agent_name);
114
115        // Track loaded path
116        self.legacy_paths_loaded.push(path.to_path_buf());
117
118        // Convert to extension manifest (consumes legacy)
119        let manifest = legacy.into_extension_manifest(path);
120
121        // Cache by agent name for future lookups
122        self.agent_type_cache.insert(agent_name, manifest.clone());
123
124        Ok(manifest)
125    }
126
127    /// Resolve an agent_type to an extension manifest
128    ///
129    /// Searches in order:
130    /// 1. Cached manifests from previous loads
131    /// 2. Project-local .scud/agents/<agent_type>.toml
132    /// 3. Built-in agent definitions (embedded)
133    ///
134    /// Returns the manifest if found, with deprecation warning for legacy formats.
135    pub fn resolve_agent_type(
136        &mut self,
137        agent_type: &str,
138        project_root: &Path,
139    ) -> Option<ExtensionManifest> {
140        // Check cache first
141        if let Some(manifest) = self.agent_type_cache.get(agent_type) {
142            return Some(manifest.clone());
143        }
144
145        // Try project-local agent definition
146        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        // Try built-in agents (from assets)
158        if let Some(manifest) = self.load_builtin_agent(agent_type) {
159            return Some(manifest);
160        }
161
162        None
163    }
164
165    /// Load a built-in agent definition by name
166    fn load_builtin_agent(&mut self, name: &str) -> Option<ExtensionManifest> {
167        // Built-in agent definitions embedded at compile time
168        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        // Don't emit deprecation warnings for built-in agents - they're managed by us
185        let legacy: LegacyAgentToml = toml::from_str(content).ok()?;
186        let manifest = legacy.into_extension_manifest(&path);
187
188        // Cache for future lookups
189        self.agent_type_cache
190            .insert(name.to_string(), manifest.clone());
191
192        Some(manifest)
193    }
194
195    /// Emit a deprecation warning for legacy format usage
196    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            // Only show warning once per session
203            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    /// Get list of paths that were loaded as legacy format
243    pub fn legacy_paths(&self) -> &[PathBuf] {
244        &self.legacy_paths_loaded
245    }
246
247    /// Check if any legacy formats were loaded
248    pub fn has_legacy_loads(&self) -> bool {
249        !self.legacy_paths_loaded.is_empty()
250    }
251
252    /// Get cached agent types
253    pub fn cached_agent_types(&self) -> Vec<&str> {
254        self.agent_type_cache.keys().map(|s| s.as_str()).collect()
255    }
256
257    /// Clear the cache
258    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
270/// Utility function to check if content is in legacy agent format
271pub fn is_legacy_agent_format(content: &str) -> bool {
272    // Try to parse as legacy format
273    toml::from_str::<LegacyAgentToml>(content).is_ok() && !content.contains("[extension]")
274}
275
276/// Utility function to check if a file is in legacy agent format
277pub 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
283/// Convert a legacy agent TOML string to new extension format string
284///
285/// This is a convenience function for migration tooling.
286pub 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    // Generate the new format TOML
296    let mut output = String::new();
297
298    // Extension metadata section
299    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    // Config section (preserved from legacy)
314    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        // Sort keys for consistent output
319        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                        // For complex types, use TOML inline
340                        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
353/// Integration with ExtensionRegistry to load legacy agents with warnings
354pub 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    // Emit summary deprecation warning if there are legacy extensions
374    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/// Statistics from migration-aware loading
384#[derive(Debug, Default)]
385pub struct MigrationStats {
386    /// Number of extensions loaded in legacy format
387    pub legacy_count: usize,
388
389    /// Number of extensions loaded in modern format
390    pub modern_count: usize,
391
392    /// Paths to legacy format files
393    pub legacy_paths: Vec<PathBuf>,
394}
395
396impl MigrationStats {
397    /// Check if there were any legacy formats loaded
398    pub fn has_legacy(&self) -> bool {
399        self.legacy_count > 0
400    }
401
402    /// Total extensions loaded
403    pub fn total(&self) -> usize {
404        self.legacy_count + self.modern_count
405    }
406}
407
408/// Emit a summary deprecation warning for multiple legacy files
409fn 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    // Show first few paths
426    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
448/// Reset the global deprecation warning flag (useful for tests)
449pub 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    // Reset warning flag before each test
459    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        // First resolve loads from file
518        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        // Second resolve uses cache
523        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        // Check it's cached
528        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        // Should find built-in builder agent
538        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        // Load a builtin agent to populate cache
630        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        // Legacy file
643        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        // Modern file
656        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        // Nonexistent file
671        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        // Create a project-local builder that overrides the builtin
707        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        // The custom model should be present
728        assert_eq!(
729            manifest.as_ref().unwrap().config.get("model"),
730            Some(&serde_json::Value::String("custom-model".to_string()))
731        );
732    }
733}