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(
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    /// Load a legacy agent from string content
104    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        // Extract name before consuming legacy struct
113        let agent_name = legacy.agent.name.clone();
114
115        // Emit deprecation warning
116        self.emit_deprecation_warning(path, &agent_name);
117
118        // Track loaded path
119        self.legacy_paths_loaded.push(path.clone());
120
121        // Convert to extension manifest (consumes legacy)
122        let manifest = legacy.into_extension_manifest(path);
123
124        // Cache by agent name for future lookups
125        self.agent_type_cache
126            .insert(agent_name, manifest.clone());
127
128        Ok(manifest)
129    }
130
131    /// Resolve an agent_type to an extension manifest
132    ///
133    /// Searches in order:
134    /// 1. Cached manifests from previous loads
135    /// 2. Project-local .scud/agents/<agent_type>.toml
136    /// 3. Built-in agent definitions (embedded)
137    ///
138    /// Returns the manifest if found, with deprecation warning for legacy formats.
139    pub fn resolve_agent_type(
140        &mut self,
141        agent_type: &str,
142        project_root: &Path,
143    ) -> Option<ExtensionManifest> {
144        // Check cache first
145        if let Some(manifest) = self.agent_type_cache.get(agent_type) {
146            return Some(manifest.clone());
147        }
148
149        // Try project-local agent definition
150        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        // Try built-in agents (from assets)
162        if let Some(manifest) = self.load_builtin_agent(agent_type) {
163            return Some(manifest);
164        }
165
166        None
167    }
168
169    /// Load a built-in agent definition by name
170    fn load_builtin_agent(&mut self, name: &str) -> Option<ExtensionManifest> {
171        // Built-in agent definitions embedded at compile time
172        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        // Don't emit deprecation warnings for built-in agents - they're managed by us
189        let legacy: LegacyAgentToml = toml::from_str(content).ok()?;
190        let manifest = legacy.into_extension_manifest(&path);
191
192        // Cache for future lookups
193        self.agent_type_cache.insert(name.to_string(), manifest.clone());
194
195        Some(manifest)
196    }
197
198    /// Emit a deprecation warning for legacy format usage
199    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            // Only show warning once per session
206            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    /// Get list of paths that were loaded as legacy format
246    pub fn legacy_paths(&self) -> &[PathBuf] {
247        &self.legacy_paths_loaded
248    }
249
250    /// Check if any legacy formats were loaded
251    pub fn has_legacy_loads(&self) -> bool {
252        !self.legacy_paths_loaded.is_empty()
253    }
254
255    /// Get cached agent types
256    pub fn cached_agent_types(&self) -> Vec<&str> {
257        self.agent_type_cache.keys().map(|s| s.as_str()).collect()
258    }
259
260    /// Clear the cache
261    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
273/// Utility function to check if content is in legacy agent format
274pub fn is_legacy_agent_format(content: &str) -> bool {
275    // Try to parse as legacy format
276    toml::from_str::<LegacyAgentToml>(content).is_ok()
277        && !content.contains("[extension]")
278}
279
280/// Utility function to check if a file is in legacy agent format
281pub 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
287/// Convert a legacy agent TOML string to new extension format string
288///
289/// This is a convenience function for migration tooling.
290pub 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    // Generate the new format TOML
300    let mut output = String::new();
301
302    // Extension metadata section
303    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    // Config section (preserved from legacy)
318    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        // Sort keys for consistent output
323        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                        // For complex types, use TOML inline
344                        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
357/// Integration with ExtensionRegistry to load legacy agents with warnings
358pub 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    // Emit summary deprecation warning if there are legacy extensions
378    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/// Statistics from migration-aware loading
388#[derive(Debug, Default)]
389pub struct MigrationStats {
390    /// Number of extensions loaded in legacy format
391    pub legacy_count: usize,
392
393    /// Number of extensions loaded in modern format
394    pub modern_count: usize,
395
396    /// Paths to legacy format files
397    pub legacy_paths: Vec<PathBuf>,
398}
399
400impl MigrationStats {
401    /// Check if there were any legacy formats loaded
402    pub fn has_legacy(&self) -> bool {
403        self.legacy_count > 0
404    }
405
406    /// Total extensions loaded
407    pub fn total(&self) -> usize {
408        self.legacy_count + self.modern_count
409    }
410}
411
412/// Emit a summary deprecation warning for multiple legacy files
413fn 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    // Show first few paths
430    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
452/// Reset the global deprecation warning flag (useful for tests)
453pub 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    // Reset warning flag before each test
463    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        // First resolve loads from file
522        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        // Second resolve uses cache
527        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        // Check it's cached
532        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        // Should find built-in builder agent
542        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        // Load a builtin agent to populate cache
634        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        // Legacy file
647        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        // Modern file
660        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        // Nonexistent file
675        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        // Create a project-local builder that overrides the builtin
711        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        // The custom model should be present
729        assert_eq!(
730            manifest.as_ref().unwrap().config.get("model"),
731            Some(&serde_json::Value::String("custom-model".to_string()))
732        );
733    }
734}