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 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#[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 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 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 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 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 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 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
610fn 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
631fn 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#[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}