Skip to main content

roboticus_cli/migrate/
mod.rs

1mod safety;
2mod transform;
3
4pub use safety::{
5    FindingCategory, SafetyFinding, SafetyVerdict, Severity, SkillSafetyReport,
6    scan_directory_safety, scan_script_safety,
7};
8
9use std::fs;
10use std::io::{self, Write};
11use std::path::{Path, PathBuf};
12
13use transform::*;
14
15// ── Public types ────────────────────────────────────────────────────────
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Direction {
19    Import,
20    Export,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum MigrationArea {
25    Config,
26    Personality,
27    Skills,
28    Sessions,
29    Cron,
30    Channels,
31    Agents,
32}
33
34impl MigrationArea {
35    fn all() -> &'static [MigrationArea] {
36        &[
37            Self::Config,
38            Self::Personality,
39            Self::Skills,
40            Self::Sessions,
41            Self::Cron,
42            Self::Channels,
43            Self::Agents,
44        ]
45    }
46
47    fn from_str(s: &str) -> Option<Self> {
48        match s.to_lowercase().as_str() {
49            "config" => Some(Self::Config),
50            "personality" => Some(Self::Personality),
51            "skills" => Some(Self::Skills),
52            "sessions" => Some(Self::Sessions),
53            "cron" => Some(Self::Cron),
54            "channels" => Some(Self::Channels),
55            "agents" => Some(Self::Agents),
56            _ => None,
57        }
58    }
59
60    fn label(&self) -> &'static str {
61        match self {
62            Self::Config => "Configuration",
63            Self::Personality => "Personality",
64            Self::Skills => "Skills",
65            Self::Sessions => "Sessions",
66            Self::Cron => "Cron Jobs",
67            Self::Channels => "Channels",
68            Self::Agents => "Sub-Agents",
69        }
70    }
71}
72
73#[derive(Debug, Clone)]
74pub struct AreaResult {
75    pub area: MigrationArea,
76    pub success: bool,
77    pub items_processed: usize,
78    pub warnings: Vec<String>,
79    pub error: Option<String>,
80}
81
82#[derive(Debug)]
83pub struct MigrationReport {
84    pub direction: Direction,
85    pub source: PathBuf,
86    pub results: Vec<AreaResult>,
87}
88
89impl MigrationReport {
90    fn print(&self) {
91        let dir_label = match self.direction {
92            Direction::Import => "Import",
93            Direction::Export => "Export",
94        };
95        eprintln!();
96        eprintln!(
97            "  \u{256d}\u{2500} Migration Report ({dir_label}) \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
98        );
99        eprintln!("  \u{2502} Source: {}", self.source.display());
100        eprintln!("  \u{2502}");
101        for r in &self.results {
102            let icon = if r.success { "\u{2714}" } else { "\u{2718}" };
103            eprintln!(
104                "  \u{2502} {icon} {:<14} {} items",
105                r.area.label(),
106                r.items_processed
107            );
108            for w in &r.warnings {
109                eprintln!("  \u{2502}   \u{26a0} {w}");
110            }
111            if let Some(e) = &r.error {
112                eprintln!("  \u{2502}   \u{2718} {e}");
113            }
114        }
115        let ok = self.results.iter().filter(|r| r.success).count();
116        let total = self.results.len();
117        eprintln!("  \u{2502}");
118        eprintln!("  \u{2502} {ok}/{total} areas completed successfully");
119        eprintln!(
120            "  \u{2570}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
121        );
122        eprintln!();
123    }
124}
125
126// ── Orchestrators ──────────────────────────────────────────────────────
127
128fn resolve_areas(area_strs: &[String]) -> Vec<MigrationArea> {
129    if area_strs.is_empty() {
130        return MigrationArea::all().to_vec();
131    }
132    area_strs
133        .iter()
134        .filter_map(|s| MigrationArea::from_str(s))
135        .collect()
136}
137
138pub fn cmd_migrate_import(
139    source: &str,
140    areas: &[String],
141    yes: bool,
142    no_safety_check: bool,
143) -> Result<(), Box<dyn std::error::Error>> {
144    let source_path = PathBuf::from(source);
145    if !source_path.exists() {
146        eprintln!("  \u{2718} Source path does not exist: {source}");
147        return Ok(());
148    }
149
150    let roboticus_root = default_roboticus_root();
151    let areas = resolve_areas(areas);
152
153    eprintln!();
154    eprintln!(
155        "  \u{256d}\u{2500} Legacy \u{2192} Roboticus Import \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
156    );
157    eprintln!("  \u{2502} Source: {}", source_path.display());
158    eprintln!("  \u{2502} Target: {}", roboticus_root.display());
159    eprintln!(
160        "  \u{2502} Areas:  {}",
161        areas
162            .iter()
163            .map(|a| a.label())
164            .collect::<Vec<_>>()
165            .join(", ")
166    );
167    eprintln!(
168        "  \u{2570}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
169    );
170
171    if !yes {
172        eprint!("  Proceed? [y/N] ");
173        let _ = io::stderr().flush();
174        let mut input = String::new();
175        io::stdin().read_line(&mut input)?;
176        if !input.trim().eq_ignore_ascii_case("y") {
177            eprintln!("  Aborted.");
178            return Ok(());
179        }
180    }
181
182    let mut results = Vec::new();
183    for area in &areas {
184        eprint!("  \u{25b8} Importing {} ... ", area.label());
185        let result = match area {
186            MigrationArea::Config => import_config(&source_path, &roboticus_root),
187            MigrationArea::Personality => import_personality(&source_path, &roboticus_root),
188            MigrationArea::Skills => import_skills(&source_path, &roboticus_root, no_safety_check),
189            MigrationArea::Sessions => import_sessions(&source_path, &roboticus_root),
190            MigrationArea::Cron => import_cron(&source_path, &roboticus_root),
191            MigrationArea::Channels => import_channels(&source_path, &roboticus_root),
192            MigrationArea::Agents => import_agents(&source_path, &roboticus_root),
193        };
194        if result.success {
195            eprintln!("\u{2714} ({} items)", result.items_processed);
196        } else {
197            eprintln!("\u{2718}");
198        }
199        results.push(result);
200    }
201
202    MigrationReport {
203        direction: Direction::Import,
204        source: source_path,
205        results,
206    }
207    .print();
208    Ok(())
209}
210
211pub fn cmd_migrate_export(
212    target: &str,
213    areas: &[String],
214) -> Result<(), Box<dyn std::error::Error>> {
215    let target_path = PathBuf::from(target);
216    let roboticus_root = default_roboticus_root();
217    let areas = resolve_areas(areas);
218
219    eprintln!();
220    eprintln!(
221        "  \u{256d}\u{2500} Roboticus \u{2192} Legacy Export \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
222    );
223    eprintln!("  \u{2502} Source: {}", roboticus_root.display());
224    eprintln!("  \u{2502} Target: {}", target_path.display());
225    eprintln!(
226        "  \u{2502} Areas:  {}",
227        areas
228            .iter()
229            .map(|a| a.label())
230            .collect::<Vec<_>>()
231            .join(", ")
232    );
233    eprintln!(
234        "  \u{2570}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
235    );
236
237    if let Err(e) = fs::create_dir_all(&target_path) {
238        eprintln!("  \u{2718} Failed to create target directory: {e}");
239        return Ok(());
240    }
241
242    let mut results = Vec::new();
243    for area in &areas {
244        eprint!("  \u{25b8} Exporting {} ... ", area.label());
245        let result = match area {
246            MigrationArea::Config => export_config(&roboticus_root, &target_path),
247            MigrationArea::Personality => export_personality(&roboticus_root, &target_path),
248            MigrationArea::Skills => export_skills(&roboticus_root, &target_path),
249            MigrationArea::Sessions => export_sessions(&roboticus_root, &target_path),
250            MigrationArea::Cron => export_cron(&roboticus_root, &target_path),
251            MigrationArea::Channels => export_channels(&roboticus_root, &target_path),
252            MigrationArea::Agents => export_agents(&roboticus_root, &target_path),
253        };
254        if result.success {
255            eprintln!("\u{2714} ({} items)", result.items_processed);
256        } else {
257            eprintln!("\u{2718}");
258        }
259        results.push(result);
260    }
261
262    MigrationReport {
263        direction: Direction::Export,
264        source: roboticus_root,
265        results,
266    }
267    .print();
268    Ok(())
269}
270
271// ── Standalone skill import/export ─────────────────────────────────────
272
273pub fn cmd_skill_import(
274    source: &str,
275    no_safety_check: bool,
276    accept_warnings: bool,
277) -> Result<(), Box<dyn std::error::Error>> {
278    let source_path = PathBuf::from(source);
279    if !source_path.exists() {
280        eprintln!("  \u{2718} Source path does not exist: {source}");
281        return Ok(());
282    }
283
284    eprintln!("  \u{25b8} Scanning skills from: {}", source_path.display());
285
286    if !no_safety_check {
287        let report = if source_path.is_dir() {
288            scan_directory_safety(&source_path)
289        } else {
290            scan_script_safety(&source_path)
291        };
292
293        report.print();
294
295        match &report.verdict {
296            SafetyVerdict::Critical(_) => {
297                eprintln!("  \u{2718} Import blocked due to critical safety findings.");
298                eprintln!("    Use --no-safety-check to override (dangerous!).");
299                return Ok(());
300            }
301            SafetyVerdict::Warnings(_) if !accept_warnings => {
302                eprint!("  \u{26a0} Warnings found. Import anyway? [y/N] ");
303                let _ = io::stderr().flush();
304                let mut input = String::new();
305                io::stdin().read_line(&mut input)?;
306                if !input.trim().eq_ignore_ascii_case("y") {
307                    eprintln!("  Aborted.");
308                    return Ok(());
309                }
310            }
311            _ => {}
312        }
313    }
314
315    let roboticus_root = default_roboticus_root();
316    let skills_dir = roboticus_root.join("skills");
317    fs::create_dir_all(&skills_dir)?;
318
319    let mut count = 0;
320    if source_path.is_dir() {
321        if let Ok(entries) = fs::read_dir(&source_path) {
322            for entry in entries.flatten() {
323                let src = entry.path();
324                let dest = skills_dir.join(entry.file_name());
325                if src.is_file() {
326                    fs::copy(&src, &dest)?;
327                    count += 1;
328                } else if src.is_dir() {
329                    copy_dir_recursive(&src, &dest)?;
330                    count += 1;
331                }
332            }
333        }
334    } else {
335        let file_name = source_path.file_name().unwrap_or_default();
336        if file_name.is_empty() {
337            return Err(format!(
338                "source path '{}' does not contain a valid file name",
339                source_path.display()
340            )
341            .into());
342        }
343        let dest = skills_dir.join(file_name);
344        fs::copy(&source_path, &dest)?;
345        count = 1;
346    }
347
348    eprintln!(
349        "  \u{2714} Imported {count} skill(s) to {}",
350        skills_dir.display()
351    );
352    Ok(())
353}
354
355pub fn cmd_skill_export(output: &str, ids: &[String]) -> Result<(), Box<dyn std::error::Error>> {
356    let roboticus_root = default_roboticus_root();
357    let skills_dir = roboticus_root.join("skills");
358
359    if !skills_dir.exists() {
360        eprintln!(
361            "  \u{2718} No skills directory found at {}",
362            skills_dir.display()
363        );
364        return Ok(());
365    }
366
367    let output_path = PathBuf::from(output);
368    fs::create_dir_all(&output_path)?;
369
370    let mut count = 0;
371    if let Ok(entries) = fs::read_dir(&skills_dir) {
372        for entry in entries.flatten() {
373            let name = entry.file_name().to_string_lossy().to_string();
374            if !ids.is_empty() && !ids.iter().any(|id| name.contains(id.as_str())) {
375                continue;
376            }
377            let src = entry.path();
378            let dest = output_path.join(entry.file_name());
379            if src.is_file() {
380                fs::copy(&src, &dest)?;
381                count += 1;
382            } else if src.is_dir() {
383                copy_dir_recursive(&src, &dest)?;
384                count += 1;
385            }
386        }
387    }
388    eprintln!(
389        "  \u{2714} Exported {count} skill(s) to {}",
390        output_path.display()
391    );
392
393    Ok(())
394}
395
396// ── Ironclad → Roboticus migration ────────────────────────────────────
397
398/// Ironclad migration areas for reporting.
399#[derive(Debug, Clone, Copy)]
400enum IroncladArea {
401    Config,
402    Skills,
403    Plugins,
404    Sessions,
405    Workspace,
406    Other,
407}
408
409impl IroncladArea {
410    fn label(&self) -> &'static str {
411        match self {
412            Self::Config => "Configuration",
413            Self::Skills => "Skills",
414            Self::Plugins => "Plugins",
415            Self::Sessions => "Sessions",
416            Self::Workspace => "Workspace",
417            Self::Other => "Other files",
418        }
419    }
420}
421
422struct IroncladAreaResult {
423    area: IroncladArea,
424    items: usize,
425    warnings: Vec<String>,
426}
427
428pub fn cmd_migrate_ironclad(
429    source: Option<&str>,
430    yes: bool,
431) -> Result<(), Box<dyn std::error::Error>> {
432    let home = roboticus_core::home_dir();
433    let source_path = match source {
434        Some(s) => PathBuf::from(s),
435        None => home.join(".ironclad"),
436    };
437    let target_path = home.join(".roboticus");
438
439    if !source_path.exists() {
440        eprintln!(
441            "  \u{2718} Source directory not found: {}",
442            source_path.display()
443        );
444        eprintln!("    Nothing to migrate.");
445        return Ok(());
446    }
447
448    let target_exists = target_path.exists();
449
450    eprintln!();
451    eprintln!(
452        "  \u{256d}\u{2500} Ironclad \u{2192} Roboticus Migration \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
453    );
454    eprintln!("  \u{2502} Source: {}", source_path.display());
455    eprintln!("  \u{2502} Target: {}", target_path.display());
456    if target_exists {
457        eprintln!(
458            "  \u{2502} \u{26a0} Target exists — will merge (copy files that don't exist in target)"
459        );
460    }
461    eprintln!(
462        "  \u{2570}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
463    );
464
465    if !yes {
466        eprint!("  Proceed? [y/N] ");
467        let _ = io::stderr().flush();
468        let mut input = String::new();
469        io::stdin().read_line(&mut input)?;
470        if !input.trim().eq_ignore_ascii_case("y") {
471            eprintln!("  Aborted.");
472            return Ok(());
473        }
474    }
475
476    let mut area_results: Vec<IroncladAreaResult> = Vec::new();
477
478    if target_exists {
479        // Merge mode: copy files that don't exist in target
480        eprint!("  \u{25b8} Merging directories ... ");
481        let mut merged = 0usize;
482        let mut warnings = Vec::new();
483        if let Err(e) = merge_dir_recursive(&source_path, &target_path, &mut merged) {
484            warnings.push(format!("merge error: {e}"));
485        }
486        eprintln!("\u{2714} ({merged} new files)");
487        area_results.push(IroncladAreaResult {
488            area: IroncladArea::Other,
489            items: merged,
490            warnings,
491        });
492    } else {
493        // Full copy
494        eprint!("  \u{25b8} Copying data directory ... ");
495        if let Err(e) = copy_dir_recursive(&source_path, &target_path) {
496            eprintln!("\u{2718}");
497            eprintln!("  \u{2718} Failed to copy: {e}");
498            return Ok(());
499        }
500        eprintln!("\u{2714}");
501    }
502
503    // Rename ironclad.toml → roboticus.toml
504    let old_config = target_path.join("ironclad.toml");
505    let new_config = target_path.join("roboticus.toml");
506    let mut config_items = 0usize;
507    let mut config_warnings = Vec::new();
508    if old_config.exists() && !new_config.exists() {
509        eprint!("  \u{25b8} Renaming config file ... ");
510        if let Err(e) = fs::rename(&old_config, &new_config) {
511            config_warnings.push(format!("rename failed: {e}"));
512            eprintln!("\u{2718}");
513        } else {
514            config_items += 1;
515            eprintln!("\u{2714}");
516        }
517    } else if old_config.exists() && new_config.exists() {
518        config_warnings
519            .push("both ironclad.toml and roboticus.toml exist; kept roboticus.toml".into());
520    }
521
522    // Rewrite legacy paths in all .toml files
523    eprint!("  \u{25b8} Rewriting legacy paths ... ");
524    roboticus_core::rewrite_all_toml_files(&target_path);
525    eprintln!("\u{2714}");
526    config_items += 1;
527
528    area_results.push(IroncladAreaResult {
529        area: IroncladArea::Config,
530        items: config_items,
531        warnings: config_warnings,
532    });
533
534    // Count what was migrated
535    let skills_dir = target_path.join("skills");
536    let plugins_dir = target_path.join("plugins");
537    let sessions_db = target_path.join("state.db");
538    let workspace = target_path.join("workspace");
539
540    let skill_count = if skills_dir.exists() {
541        fs::read_dir(&skills_dir)
542            .map(|entries| entries.flatten().count())
543            .unwrap_or(0)
544    } else {
545        0
546    };
547    area_results.push(IroncladAreaResult {
548        area: IroncladArea::Skills,
549        items: skill_count,
550        warnings: vec![],
551    });
552
553    let plugin_count = if plugins_dir.exists() {
554        fs::read_dir(&plugins_dir)
555            .map(|entries| entries.flatten().count())
556            .unwrap_or(0)
557    } else {
558        0
559    };
560    area_results.push(IroncladAreaResult {
561        area: IroncladArea::Plugins,
562        items: plugin_count,
563        warnings: vec![],
564    });
565
566    area_results.push(IroncladAreaResult {
567        area: IroncladArea::Sessions,
568        items: if sessions_db.exists() { 1 } else { 0 },
569        warnings: vec![],
570    });
571
572    area_results.push(IroncladAreaResult {
573        area: IroncladArea::Workspace,
574        items: if workspace.exists() { 1 } else { 0 },
575        warnings: vec![],
576    });
577
578    // Print report
579    eprintln!();
580    eprintln!(
581        "  \u{256d}\u{2500} Migration Report \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
582    );
583    for r in &area_results {
584        eprintln!(
585            "  \u{2502} \u{2714} {:<14} {} items",
586            r.area.label(),
587            r.items
588        );
589        for w in &r.warnings {
590            eprintln!("  \u{2502}   \u{26a0} {w}");
591        }
592    }
593    eprintln!("  \u{2502}");
594    eprintln!(
595        "  \u{2502} Migration complete. Source directory left intact at: {}",
596        source_path.display()
597    );
598    eprintln!(
599        "  \u{2502} You may remove it when satisfied: rm -rf {}",
600        source_path.display()
601    );
602    eprintln!(
603        "  \u{2570}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
604    );
605    eprintln!();
606
607    Ok(())
608}
609
610/// Recursively copy files from `src` to `dst`, skipping files that already exist in `dst`.
611fn merge_dir_recursive(src: &Path, dst: &Path, count: &mut usize) -> io::Result<()> {
612    fs::create_dir_all(dst)?;
613    for entry in fs::read_dir(src)? {
614        let entry = entry?;
615        let src_path = entry.path();
616        let dst_path = dst.join(entry.file_name());
617        let ft = entry.file_type()?;
618        if ft.is_symlink() {
619            continue;
620        }
621        if ft.is_dir() {
622            merge_dir_recursive(&src_path, &dst_path, count)?;
623        } else if ft.is_file() && !dst_path.exists() {
624            fs::copy(&src_path, &dst_path)?;
625            *count += 1;
626        }
627    }
628    Ok(())
629}
630
631// ── Helpers ────────────────────────────────────────────────────────────
632
633fn default_roboticus_root() -> PathBuf {
634    roboticus_core::home_dir().join(".roboticus")
635}
636
637pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
638    fs::create_dir_all(dst)?;
639    for entry in fs::read_dir(src)? {
640        let entry = entry?;
641        let src_path = entry.path();
642        let dst_path = dst.join(entry.file_name());
643        let ft = entry.file_type()?;
644        if ft.is_symlink() {
645            continue;
646        }
647        if ft.is_dir() {
648            copy_dir_recursive(&src_path, &dst_path)?;
649        } else if ft.is_file() {
650            fs::copy(&src_path, &dst_path)?;
651        }
652    }
653    Ok(())
654}
655
656// ── Tests ──────────────────────────────────────────────────────────────
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use tempfile::TempDir;
662
663    #[test]
664    fn resolve_areas_empty_returns_all() {
665        assert_eq!(resolve_areas(&[]).len(), 7);
666    }
667
668    #[test]
669    fn resolve_areas_specific() {
670        let areas = resolve_areas(&["config".into(), "skills".into()]);
671        assert_eq!(areas.len(), 2);
672        assert!(areas.contains(&MigrationArea::Config));
673        assert!(areas.contains(&MigrationArea::Skills));
674    }
675
676    #[test]
677    fn resolve_areas_invalid_filtered() {
678        assert_eq!(
679            resolve_areas(&["config".into(), "nonsense".into()]).len(),
680            1
681        );
682    }
683
684    #[test]
685    fn migration_area_labels() {
686        assert_eq!(MigrationArea::Config.label(), "Configuration");
687        assert_eq!(MigrationArea::Personality.label(), "Personality");
688        assert_eq!(MigrationArea::Skills.label(), "Skills");
689        assert_eq!(MigrationArea::Sessions.label(), "Sessions");
690        assert_eq!(MigrationArea::Cron.label(), "Cron Jobs");
691        assert_eq!(MigrationArea::Channels.label(), "Channels");
692        assert_eq!(MigrationArea::Agents.label(), "Sub-Agents");
693    }
694
695    #[test]
696    fn migration_area_from_str_valid() {
697        assert_eq!(
698            MigrationArea::from_str("config"),
699            Some(MigrationArea::Config)
700        );
701        assert_eq!(
702            MigrationArea::from_str("CONFIG"),
703            Some(MigrationArea::Config)
704        );
705    }
706
707    #[test]
708    fn migration_area_from_str_invalid() {
709        assert_eq!(MigrationArea::from_str("nonsense"), None);
710    }
711
712    #[test]
713    fn copy_dir_recursive_works() {
714        let src = TempDir::new().unwrap();
715        let dst = TempDir::new().unwrap();
716        fs::create_dir_all(src.path().join("sub")).unwrap();
717        fs::write(src.path().join("a.txt"), "hello").unwrap();
718        fs::write(src.path().join("sub/b.txt"), "world").unwrap();
719        let target = dst.path().join("copy");
720        copy_dir_recursive(src.path(), &target).unwrap();
721        assert_eq!(fs::read_to_string(target.join("a.txt")).unwrap(), "hello");
722        assert_eq!(
723            fs::read_to_string(target.join("sub/b.txt")).unwrap(),
724            "world"
725        );
726    }
727
728    #[cfg(unix)]
729    #[test]
730    fn copy_dir_recursive_skips_symlinks() {
731        use std::os::unix::fs::symlink;
732        let src = TempDir::new().unwrap();
733        let dst = TempDir::new().unwrap();
734        fs::write(src.path().join("real.txt"), "ok").unwrap();
735        symlink(src.path().join("real.txt"), src.path().join("link.txt")).unwrap();
736        let target = dst.path().join("copy");
737        copy_dir_recursive(src.path(), &target).unwrap();
738        assert!(target.join("real.txt").exists());
739        assert!(!target.join("link.txt").exists());
740    }
741
742    #[test]
743    fn qt_escapes() {
744        assert_eq!(transform::qt("hello"), "\"hello\"");
745        assert_eq!(transform::qt("he\"llo"), "\"he\\\"llo\"");
746    }
747
748    #[test]
749    fn migration_area_all_returns_seven() {
750        assert_eq!(MigrationArea::all().len(), 7);
751    }
752
753    #[test]
754    fn direction_debug_and_eq() {
755        assert_eq!(Direction::Import, Direction::Import);
756        assert_ne!(Direction::Import, Direction::Export);
757        assert_eq!(format!("{:?}", Direction::Export), "Export");
758    }
759
760    #[test]
761    fn migration_area_from_str_all_variants() {
762        for s in &[
763            "config",
764            "personality",
765            "skills",
766            "sessions",
767            "cron",
768            "channels",
769            "agents",
770        ] {
771            assert!(MigrationArea::from_str(s).is_some(), "failed for: {s}");
772        }
773    }
774
775    #[test]
776    fn migration_area_from_str_case_insensitive() {
777        assert_eq!(
778            MigrationArea::from_str("Personality"),
779            Some(MigrationArea::Personality)
780        );
781        assert_eq!(
782            MigrationArea::from_str("SESSIONS"),
783            Some(MigrationArea::Sessions)
784        );
785        assert_eq!(MigrationArea::from_str("CrOn"), Some(MigrationArea::Cron));
786    }
787
788    #[test]
789    fn area_result_construction() {
790        let r = AreaResult {
791            area: MigrationArea::Config,
792            success: true,
793            items_processed: 5,
794            warnings: vec!["warn1".into()],
795            error: None,
796        };
797        assert!(r.success);
798        assert_eq!(r.items_processed, 5);
799        assert_eq!(r.warnings.len(), 1);
800        assert!(r.error.is_none());
801    }
802
803    #[test]
804    fn area_result_failure() {
805        let r = AreaResult {
806            area: MigrationArea::Skills,
807            success: false,
808            items_processed: 0,
809            warnings: vec![],
810            error: Some("something broke".into()),
811        };
812        assert!(!r.success);
813        assert_eq!(r.error.unwrap(), "something broke");
814    }
815
816    #[test]
817    fn default_roboticus_root_contains_roboticus() {
818        let root = default_roboticus_root();
819        assert!(root.to_string_lossy().contains(".roboticus"));
820    }
821
822    #[test]
823    fn copy_dir_recursive_empty_dir() {
824        let src = TempDir::new().unwrap();
825        let dst = TempDir::new().unwrap();
826        let target = dst.path().join("empty_copy");
827        copy_dir_recursive(src.path(), &target).unwrap();
828        assert!(target.exists());
829    }
830
831    #[test]
832    fn resolve_areas_all_invalid_returns_empty() {
833        let areas = resolve_areas(&["foo".into(), "bar".into()]);
834        assert!(areas.is_empty());
835    }
836
837    #[test]
838    fn migration_report_print_does_not_panic() {
839        let report = MigrationReport {
840            direction: Direction::Import,
841            source: PathBuf::from("/tmp/test"),
842            results: vec![
843                AreaResult {
844                    area: MigrationArea::Config,
845                    success: true,
846                    items_processed: 3,
847                    warnings: vec!["minor issue".into()],
848                    error: None,
849                },
850                AreaResult {
851                    area: MigrationArea::Skills,
852                    success: false,
853                    items_processed: 0,
854                    warnings: vec![],
855                    error: Some("failed".into()),
856                },
857            ],
858        };
859        report.print();
860    }
861
862    #[test]
863    fn qt_empty_string() {
864        assert_eq!(transform::qt(""), "\"\"");
865    }
866
867    #[test]
868    fn qt_backslash() {
869        let result = transform::qt("a\\b");
870        assert!(result.contains("\\\\"));
871    }
872
873    #[test]
874    fn qt_ml_wraps_in_triple_quotes() {
875        let result = transform::qt_ml("line1\nline2");
876        assert!(result.starts_with("\"\"\"\n"));
877        assert!(result.ends_with("\n\"\"\""));
878        assert!(result.contains("line1\nline2"));
879    }
880
881    #[test]
882    fn titlecase_single_word() {
883        assert_eq!(transform::titlecase("hello"), "Hello");
884    }
885
886    #[test]
887    fn titlecase_underscored() {
888        assert_eq!(transform::titlecase("hello_world"), "Hello World");
889    }
890
891    #[test]
892    fn titlecase_empty() {
893        assert_eq!(transform::titlecase(""), "");
894    }
895
896    #[test]
897    fn import_config_basic() {
898        let oc = TempDir::new().unwrap();
899        let ic = TempDir::new().unwrap();
900        let config = serde_json::json!({
901            "name": "TestBot",
902            "model": "gpt-4"
903        });
904        fs::write(
905            oc.path().join("legacy.json"),
906            serde_json::to_string(&config).unwrap(),
907        )
908        .unwrap();
909        let r = transform::import_config(oc.path(), ic.path());
910        assert!(r.success);
911        assert!(ic.path().join("roboticus.toml").exists());
912    }
913
914    #[test]
915    fn export_config_missing_toml_fails() {
916        let ic = TempDir::new().unwrap();
917        let oc = TempDir::new().unwrap();
918        let r = transform::export_config(ic.path(), oc.path());
919        assert!(!r.success);
920    }
921
922    #[test]
923    fn export_personality_missing_files_warns() {
924        let ic = TempDir::new().unwrap();
925        let oc = TempDir::new().unwrap();
926        fs::create_dir_all(ic.path().join("workspace")).unwrap();
927        let r = transform::export_personality(ic.path(), oc.path());
928        assert!(r.success);
929        assert_eq!(r.items_processed, 0);
930    }
931
932    #[test]
933    fn export_sessions_no_database() {
934        let ic = TempDir::new().unwrap();
935        let oc = TempDir::new().unwrap();
936        let r = transform::export_sessions(ic.path(), oc.path());
937        assert!(r.success);
938        assert_eq!(r.items_processed, 0);
939    }
940
941    #[test]
942    fn export_cron_no_database() {
943        let ic = TempDir::new().unwrap();
944        let oc = TempDir::new().unwrap();
945        let r = transform::export_cron(ic.path(), oc.path());
946        assert!(r.success);
947        assert_eq!(r.items_processed, 0);
948    }
949
950    #[test]
951    fn export_skills_no_skills_dir() {
952        let ic = TempDir::new().unwrap();
953        let oc = TempDir::new().unwrap();
954        let r = transform::export_skills(ic.path(), oc.path());
955        assert!(r.success);
956        assert_eq!(r.items_processed, 0);
957    }
958
959    #[test]
960    fn export_channels_no_config() {
961        let ic = TempDir::new().unwrap();
962        let oc = TempDir::new().unwrap();
963        let r = transform::export_channels(ic.path(), oc.path());
964        assert!(r.success);
965        assert_eq!(r.items_processed, 0);
966    }
967}