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 dest = skills_dir.join(source_path.file_name().unwrap_or_default());
336        fs::copy(&source_path, &dest)?;
337        count = 1;
338    }
339
340    eprintln!(
341        "  \u{2714} Imported {count} skill(s) to {}",
342        skills_dir.display()
343    );
344    Ok(())
345}
346
347pub fn cmd_skill_export(output: &str, ids: &[String]) -> Result<(), Box<dyn std::error::Error>> {
348    let roboticus_root = default_roboticus_root();
349    let skills_dir = roboticus_root.join("skills");
350
351    if !skills_dir.exists() {
352        eprintln!(
353            "  \u{2718} No skills directory found at {}",
354            skills_dir.display()
355        );
356        return Ok(());
357    }
358
359    let output_path = PathBuf::from(output);
360    fs::create_dir_all(&output_path)?;
361
362    let mut count = 0;
363    if let Ok(entries) = fs::read_dir(&skills_dir) {
364        for entry in entries.flatten() {
365            let name = entry.file_name().to_string_lossy().to_string();
366            if !ids.is_empty() && !ids.iter().any(|id| name.contains(id.as_str())) {
367                continue;
368            }
369            let src = entry.path();
370            let dest = output_path.join(entry.file_name());
371            if src.is_file() {
372                fs::copy(&src, &dest)?;
373                count += 1;
374            } else if src.is_dir() {
375                copy_dir_recursive(&src, &dest)?;
376                count += 1;
377            }
378        }
379    }
380    eprintln!(
381        "  \u{2714} Exported {count} skill(s) to {}",
382        output_path.display()
383    );
384
385    Ok(())
386}
387
388// ── Helpers ────────────────────────────────────────────────────────────
389
390fn default_roboticus_root() -> PathBuf {
391    roboticus_core::home_dir().join(".roboticus")
392}
393
394pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
395    fs::create_dir_all(dst)?;
396    for entry in fs::read_dir(src)? {
397        let entry = entry?;
398        let src_path = entry.path();
399        let dst_path = dst.join(entry.file_name());
400        let ft = entry.file_type()?;
401        if ft.is_symlink() {
402            continue;
403        }
404        if ft.is_dir() {
405            copy_dir_recursive(&src_path, &dst_path)?;
406        } else if ft.is_file() {
407            fs::copy(&src_path, &dst_path)?;
408        }
409    }
410    Ok(())
411}
412
413// ── Tests ──────────────────────────────────────────────────────────────
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use tempfile::TempDir;
419
420    #[test]
421    fn resolve_areas_empty_returns_all() {
422        assert_eq!(resolve_areas(&[]).len(), 7);
423    }
424
425    #[test]
426    fn resolve_areas_specific() {
427        let areas = resolve_areas(&["config".into(), "skills".into()]);
428        assert_eq!(areas.len(), 2);
429        assert!(areas.contains(&MigrationArea::Config));
430        assert!(areas.contains(&MigrationArea::Skills));
431    }
432
433    #[test]
434    fn resolve_areas_invalid_filtered() {
435        assert_eq!(
436            resolve_areas(&["config".into(), "nonsense".into()]).len(),
437            1
438        );
439    }
440
441    #[test]
442    fn migration_area_labels() {
443        assert_eq!(MigrationArea::Config.label(), "Configuration");
444        assert_eq!(MigrationArea::Personality.label(), "Personality");
445        assert_eq!(MigrationArea::Skills.label(), "Skills");
446        assert_eq!(MigrationArea::Sessions.label(), "Sessions");
447        assert_eq!(MigrationArea::Cron.label(), "Cron Jobs");
448        assert_eq!(MigrationArea::Channels.label(), "Channels");
449        assert_eq!(MigrationArea::Agents.label(), "Sub-Agents");
450    }
451
452    #[test]
453    fn migration_area_from_str_valid() {
454        assert_eq!(
455            MigrationArea::from_str("config"),
456            Some(MigrationArea::Config)
457        );
458        assert_eq!(
459            MigrationArea::from_str("CONFIG"),
460            Some(MigrationArea::Config)
461        );
462    }
463
464    #[test]
465    fn migration_area_from_str_invalid() {
466        assert_eq!(MigrationArea::from_str("nonsense"), None);
467    }
468
469    #[test]
470    fn copy_dir_recursive_works() {
471        let src = TempDir::new().unwrap();
472        let dst = TempDir::new().unwrap();
473        fs::create_dir_all(src.path().join("sub")).unwrap();
474        fs::write(src.path().join("a.txt"), "hello").unwrap();
475        fs::write(src.path().join("sub/b.txt"), "world").unwrap();
476        let target = dst.path().join("copy");
477        copy_dir_recursive(src.path(), &target).unwrap();
478        assert_eq!(fs::read_to_string(target.join("a.txt")).unwrap(), "hello");
479        assert_eq!(
480            fs::read_to_string(target.join("sub/b.txt")).unwrap(),
481            "world"
482        );
483    }
484
485    #[cfg(unix)]
486    #[test]
487    fn copy_dir_recursive_skips_symlinks() {
488        use std::os::unix::fs::symlink;
489        let src = TempDir::new().unwrap();
490        let dst = TempDir::new().unwrap();
491        fs::write(src.path().join("real.txt"), "ok").unwrap();
492        symlink(src.path().join("real.txt"), src.path().join("link.txt")).unwrap();
493        let target = dst.path().join("copy");
494        copy_dir_recursive(src.path(), &target).unwrap();
495        assert!(target.join("real.txt").exists());
496        assert!(!target.join("link.txt").exists());
497    }
498
499    #[test]
500    fn qt_escapes() {
501        assert_eq!(transform::qt("hello"), "\"hello\"");
502        assert_eq!(transform::qt("he\"llo"), "\"he\\\"llo\"");
503    }
504
505    #[test]
506    fn migration_area_all_returns_seven() {
507        assert_eq!(MigrationArea::all().len(), 7);
508    }
509
510    #[test]
511    fn direction_debug_and_eq() {
512        assert_eq!(Direction::Import, Direction::Import);
513        assert_ne!(Direction::Import, Direction::Export);
514        assert_eq!(format!("{:?}", Direction::Export), "Export");
515    }
516
517    #[test]
518    fn migration_area_from_str_all_variants() {
519        for s in &[
520            "config",
521            "personality",
522            "skills",
523            "sessions",
524            "cron",
525            "channels",
526            "agents",
527        ] {
528            assert!(MigrationArea::from_str(s).is_some(), "failed for: {s}");
529        }
530    }
531
532    #[test]
533    fn migration_area_from_str_case_insensitive() {
534        assert_eq!(
535            MigrationArea::from_str("Personality"),
536            Some(MigrationArea::Personality)
537        );
538        assert_eq!(
539            MigrationArea::from_str("SESSIONS"),
540            Some(MigrationArea::Sessions)
541        );
542        assert_eq!(MigrationArea::from_str("CrOn"), Some(MigrationArea::Cron));
543    }
544
545    #[test]
546    fn area_result_construction() {
547        let r = AreaResult {
548            area: MigrationArea::Config,
549            success: true,
550            items_processed: 5,
551            warnings: vec!["warn1".into()],
552            error: None,
553        };
554        assert!(r.success);
555        assert_eq!(r.items_processed, 5);
556        assert_eq!(r.warnings.len(), 1);
557        assert!(r.error.is_none());
558    }
559
560    #[test]
561    fn area_result_failure() {
562        let r = AreaResult {
563            area: MigrationArea::Skills,
564            success: false,
565            items_processed: 0,
566            warnings: vec![],
567            error: Some("something broke".into()),
568        };
569        assert!(!r.success);
570        assert_eq!(r.error.unwrap(), "something broke");
571    }
572
573    #[test]
574    fn default_roboticus_root_contains_roboticus() {
575        let root = default_roboticus_root();
576        assert!(root.to_string_lossy().contains(".roboticus"));
577    }
578
579    #[test]
580    fn copy_dir_recursive_empty_dir() {
581        let src = TempDir::new().unwrap();
582        let dst = TempDir::new().unwrap();
583        let target = dst.path().join("empty_copy");
584        copy_dir_recursive(src.path(), &target).unwrap();
585        assert!(target.exists());
586    }
587
588    #[test]
589    fn resolve_areas_all_invalid_returns_empty() {
590        let areas = resolve_areas(&["foo".into(), "bar".into()]);
591        assert!(areas.is_empty());
592    }
593
594    #[test]
595    fn migration_report_print_does_not_panic() {
596        let report = MigrationReport {
597            direction: Direction::Import,
598            source: PathBuf::from("/tmp/test"),
599            results: vec![
600                AreaResult {
601                    area: MigrationArea::Config,
602                    success: true,
603                    items_processed: 3,
604                    warnings: vec!["minor issue".into()],
605                    error: None,
606                },
607                AreaResult {
608                    area: MigrationArea::Skills,
609                    success: false,
610                    items_processed: 0,
611                    warnings: vec![],
612                    error: Some("failed".into()),
613                },
614            ],
615        };
616        report.print();
617    }
618
619    #[test]
620    fn qt_empty_string() {
621        assert_eq!(transform::qt(""), "\"\"");
622    }
623
624    #[test]
625    fn qt_backslash() {
626        let result = transform::qt("a\\b");
627        assert!(result.contains("\\\\"));
628    }
629
630    #[test]
631    fn qt_ml_wraps_in_triple_quotes() {
632        let result = transform::qt_ml("line1\nline2");
633        assert!(result.starts_with("\"\"\"\n"));
634        assert!(result.ends_with("\n\"\"\""));
635        assert!(result.contains("line1\nline2"));
636    }
637
638    #[test]
639    fn titlecase_single_word() {
640        assert_eq!(transform::titlecase("hello"), "Hello");
641    }
642
643    #[test]
644    fn titlecase_underscored() {
645        assert_eq!(transform::titlecase("hello_world"), "Hello World");
646    }
647
648    #[test]
649    fn titlecase_empty() {
650        assert_eq!(transform::titlecase(""), "");
651    }
652
653    #[test]
654    fn import_config_basic() {
655        let oc = TempDir::new().unwrap();
656        let ic = TempDir::new().unwrap();
657        let config = serde_json::json!({
658            "name": "TestBot",
659            "model": "gpt-4"
660        });
661        fs::write(
662            oc.path().join("legacy.json"),
663            serde_json::to_string(&config).unwrap(),
664        )
665        .unwrap();
666        let r = transform::import_config(oc.path(), ic.path());
667        assert!(r.success);
668        assert!(ic.path().join("roboticus.toml").exists());
669    }
670
671    #[test]
672    fn export_config_missing_toml_fails() {
673        let ic = TempDir::new().unwrap();
674        let oc = TempDir::new().unwrap();
675        let r = transform::export_config(ic.path(), oc.path());
676        assert!(!r.success);
677    }
678
679    #[test]
680    fn export_personality_missing_files_warns() {
681        let ic = TempDir::new().unwrap();
682        let oc = TempDir::new().unwrap();
683        fs::create_dir_all(ic.path().join("workspace")).unwrap();
684        let r = transform::export_personality(ic.path(), oc.path());
685        assert!(r.success);
686        assert_eq!(r.items_processed, 0);
687    }
688
689    #[test]
690    fn export_sessions_no_database() {
691        let ic = TempDir::new().unwrap();
692        let oc = TempDir::new().unwrap();
693        let r = transform::export_sessions(ic.path(), oc.path());
694        assert!(r.success);
695        assert_eq!(r.items_processed, 0);
696    }
697
698    #[test]
699    fn export_cron_no_database() {
700        let ic = TempDir::new().unwrap();
701        let oc = TempDir::new().unwrap();
702        let r = transform::export_cron(ic.path(), oc.path());
703        assert!(r.success);
704        assert_eq!(r.items_processed, 0);
705    }
706
707    #[test]
708    fn export_skills_no_skills_dir() {
709        let ic = TempDir::new().unwrap();
710        let oc = TempDir::new().unwrap();
711        let r = transform::export_skills(ic.path(), oc.path());
712        assert!(r.success);
713        assert_eq!(r.items_processed, 0);
714    }
715
716    #[test]
717    fn export_channels_no_config() {
718        let ic = TempDir::new().unwrap();
719        let oc = TempDir::new().unwrap();
720        let r = transform::export_channels(ic.path(), oc.path());
721        assert!(r.success);
722        assert_eq!(r.items_processed, 0);
723    }
724}