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#[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
126fn 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
271pub 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
388fn 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#[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}