1use regex::Regex;
5use serde::{Deserialize, Serialize};
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ParsedGitHubUrl {
11 pub owner: String,
12 pub repo: String,
13}
14
15pub fn parse_github_url(url: &str) -> Option<ParsedGitHubUrl> {
17 let trimmed = url.trim();
18
19 let patterns = [
20 r"^https?://github\.com/([^/]+)/([^/\s#?.]+)",
21 r"^git@github\.com:([^/]+)/([^/\s#?.]+)",
22 r"^github\.com/([^/]+)/([^/\s#?.]+)",
23 ];
24
25 for pattern in &patterns {
26 if let Ok(re) = Regex::new(pattern) {
27 if let Some(caps) = re.captures(trimmed) {
28 let owner = caps.get(1)?.as_str().to_string();
29 let repo = caps.get(2)?.as_str().trim_end_matches(".git").to_string();
30 return Some(ParsedGitHubUrl { owner, repo });
31 }
32 }
33 }
34
35 if let Ok(re) = Regex::new(r"^([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)$") {
36 if let Some(caps) = re.captures(trimmed) {
37 if !trimmed.contains('\\') && !trimmed.contains(':') {
38 let owner = caps.get(1)?.as_str().to_string();
39 let repo = caps.get(2)?.as_str().to_string();
40 return Some(ParsedGitHubUrl { owner, repo });
41 }
42 }
43 }
44
45 None
46}
47
48pub fn get_clone_base_dir() -> PathBuf {
50 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
51 if cwd.parent().is_none() {
52 if let Some(home) = dirs::home_dir() {
53 return home.join(".routa").join("repos");
54 }
55 }
56 cwd.join(".routa").join("repos")
57}
58
59pub fn repo_to_dir_name(owner: &str, repo: &str) -> String {
60 format!("{}--{}", owner, repo)
61}
62
63pub fn dir_name_to_repo(dir_name: &str) -> String {
64 let parts: Vec<&str> = dir_name.splitn(2, "--").collect();
65 if parts.len() == 2 {
66 format!("{}/{}", parts[0], parts[1])
67 } else {
68 dir_name.to_string()
69 }
70}
71
72pub fn is_git_repository(repo_path: &str) -> bool {
73 Command::new("git")
74 .args(["rev-parse", "--git-dir"])
75 .current_dir(repo_path)
76 .output()
77 .map(|o| o.status.success())
78 .unwrap_or(false)
79}
80
81pub fn get_current_branch(repo_path: &str) -> Option<String> {
82 let output = Command::new("git")
83 .args(["rev-parse", "--abbrev-ref", "HEAD"])
84 .current_dir(repo_path)
85 .output()
86 .ok()?;
87 if output.status.success() {
88 let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
89 if s.is_empty() {
90 None
91 } else {
92 Some(s)
93 }
94 } else {
95 None
96 }
97}
98
99pub fn list_local_branches(repo_path: &str) -> Vec<String> {
100 Command::new("git")
101 .args(["branch", "--format=%(refname:short)"])
102 .current_dir(repo_path)
103 .output()
104 .ok()
105 .filter(|o| o.status.success())
106 .map(|o| {
107 String::from_utf8_lossy(&o.stdout)
108 .lines()
109 .map(|l| l.trim().to_string())
110 .filter(|l| !l.is_empty())
111 .collect()
112 })
113 .unwrap_or_default()
114}
115
116pub fn list_remote_branches(repo_path: &str) -> Vec<String> {
117 Command::new("git")
118 .args(["branch", "-r", "--format=%(refname:short)"])
119 .current_dir(repo_path)
120 .output()
121 .ok()
122 .filter(|o| o.status.success())
123 .map(|o| {
124 String::from_utf8_lossy(&o.stdout)
125 .lines()
126 .map(|l| l.trim().to_string())
127 .filter(|l| !l.is_empty() && !l.contains("HEAD"))
128 .map(|l| l.trim_start_matches("origin/").to_string())
129 .collect()
130 })
131 .unwrap_or_default()
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct RepoBranchInfo {
136 pub current: String,
137 pub branches: Vec<String>,
138}
139
140pub fn get_branch_info(repo_path: &str) -> RepoBranchInfo {
141 RepoBranchInfo {
142 current: get_current_branch(repo_path).unwrap_or_else(|| "unknown".into()),
143 branches: list_local_branches(repo_path),
144 }
145}
146
147pub fn checkout_branch(repo_path: &str, branch: &str) -> bool {
148 let ok = Command::new("git")
149 .args(["checkout", branch])
150 .current_dir(repo_path)
151 .output()
152 .map(|o| o.status.success())
153 .unwrap_or(false);
154 if ok {
155 return true;
156 }
157 Command::new("git")
158 .args(["checkout", "-b", branch])
159 .current_dir(repo_path)
160 .output()
161 .map(|o| o.status.success())
162 .unwrap_or(false)
163}
164
165pub fn fetch_remote(repo_path: &str) -> bool {
166 Command::new("git")
167 .args(["fetch", "--all", "--prune"])
168 .current_dir(repo_path)
169 .output()
170 .map(|o| o.status.success())
171 .unwrap_or(false)
172}
173
174pub fn pull_branch(repo_path: &str) -> Result<(), String> {
175 let output = Command::new("git")
176 .args(["pull", "--ff-only"])
177 .current_dir(repo_path)
178 .output()
179 .map_err(|e| e.to_string())?;
180 if output.status.success() {
181 Ok(())
182 } else {
183 Err(String::from_utf8_lossy(&output.stderr).to_string())
184 }
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct BranchStatus {
190 pub ahead: i32,
191 pub behind: i32,
192 pub has_uncommitted_changes: bool,
193}
194
195pub fn get_branch_status(repo_path: &str, branch: &str) -> BranchStatus {
196 let mut result = BranchStatus {
197 ahead: 0,
198 behind: 0,
199 has_uncommitted_changes: false,
200 };
201
202 if let Ok(o) = Command::new("git")
203 .args([
204 "rev-list",
205 "--left-right",
206 "--count",
207 &format!("{}...origin/{}", branch, branch),
208 ])
209 .current_dir(repo_path)
210 .output()
211 {
212 if o.status.success() {
213 let text = String::from_utf8_lossy(&o.stdout);
214 let parts: Vec<&str> = text.split_whitespace().collect();
215 if parts.len() == 2 {
216 result.ahead = parts[0].parse().unwrap_or(0);
217 result.behind = parts[1].parse().unwrap_or(0);
218 }
219 }
220 }
221
222 if let Ok(o) = Command::new("git")
223 .args(["status", "--porcelain", "-uall"])
224 .current_dir(repo_path)
225 .output()
226 {
227 if o.status.success() {
228 result.has_uncommitted_changes = !String::from_utf8_lossy(&o.stdout).trim().is_empty();
229 }
230 }
231
232 result
233}
234
235pub fn reset_local_changes(repo_path: &str) -> Result<(), String> {
236 let reset_output = Command::new("git")
237 .args(["reset", "--hard", "HEAD"])
238 .current_dir(repo_path)
239 .output()
240 .map_err(|e| e.to_string())?;
241 if !reset_output.status.success() {
242 return Err(String::from_utf8_lossy(&reset_output.stderr)
243 .trim()
244 .to_string());
245 }
246
247 let clean_output = Command::new("git")
248 .args(["clean", "-fd"])
249 .current_dir(repo_path)
250 .output()
251 .map_err(|e| e.to_string())?;
252 if !clean_output.status.success() {
253 return Err(String::from_utf8_lossy(&clean_output.stderr)
254 .trim()
255 .to_string());
256 }
257
258 Ok(())
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
262#[serde(rename_all = "camelCase")]
263pub struct RepoStatus {
264 pub clean: bool,
265 pub ahead: i32,
266 pub behind: i32,
267 pub modified: i32,
268 pub untracked: i32,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
272#[serde(rename_all = "camelCase")]
273pub enum FileChangeStatus {
274 Modified,
275 Added,
276 Deleted,
277 Renamed,
278 Copied,
279 Untracked,
280 Typechange,
281 Conflicted,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
285#[serde(rename_all = "camelCase")]
286pub struct GitFileChange {
287 pub path: String,
288 pub status: FileChangeStatus,
289 #[serde(skip_serializing_if = "Option::is_none")]
290 pub previous_path: Option<String>,
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
294#[serde(rename_all = "camelCase")]
295pub struct RepoChanges {
296 pub branch: String,
297 pub status: RepoStatus,
298 pub files: Vec<GitFileChange>,
299}
300
301pub fn get_repo_status(repo_path: &str) -> RepoStatus {
302 let mut status = RepoStatus {
303 clean: true,
304 ahead: 0,
305 behind: 0,
306 modified: 0,
307 untracked: 0,
308 };
309
310 if let Ok(o) = Command::new("git")
311 .args(["status", "--porcelain", "-uall"])
312 .current_dir(repo_path)
313 .output()
314 {
315 if o.status.success() {
316 let text = String::from_utf8_lossy(&o.stdout);
317 let lines: Vec<&str> = text.lines().filter(|l| !l.is_empty()).collect();
318 status.modified = lines.iter().filter(|l| !l.starts_with("??")).count() as i32;
319 status.untracked = lines.iter().filter(|l| l.starts_with("??")).count() as i32;
320 status.clean = lines.is_empty();
321 }
322 }
323
324 if let Ok(o) = Command::new("git")
325 .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
326 .current_dir(repo_path)
327 .output()
328 {
329 if o.status.success() {
330 let text = String::from_utf8_lossy(&o.stdout);
331 let parts: Vec<&str> = text.split_whitespace().collect();
332 if parts.len() == 2 {
333 status.ahead = parts[0].parse().unwrap_or(0);
334 status.behind = parts[1].parse().unwrap_or(0);
335 }
336 }
337 }
338
339 status
340}
341
342fn map_porcelain_status(code: &str) -> FileChangeStatus {
343 if code == "??" {
344 return FileChangeStatus::Untracked;
345 }
346
347 let mut chars = code.chars();
348 let index_status = chars.next().unwrap_or(' ');
349 let worktree_status = chars.next().unwrap_or(' ');
350
351 if index_status == 'U' || worktree_status == 'U' || code == "AA" || code == "DD" {
352 return FileChangeStatus::Conflicted;
353 }
354 if index_status == 'R' || worktree_status == 'R' {
355 return FileChangeStatus::Renamed;
356 }
357 if index_status == 'C' || worktree_status == 'C' {
358 return FileChangeStatus::Copied;
359 }
360 if index_status == 'A' || worktree_status == 'A' {
361 return FileChangeStatus::Added;
362 }
363 if index_status == 'D' || worktree_status == 'D' {
364 return FileChangeStatus::Deleted;
365 }
366 if index_status == 'T' || worktree_status == 'T' {
367 return FileChangeStatus::Typechange;
368 }
369 FileChangeStatus::Modified
370}
371
372pub fn parse_git_status_porcelain(output: &str) -> Vec<GitFileChange> {
373 output
374 .lines()
375 .filter(|line| !line.trim().is_empty())
376 .filter_map(|line| {
377 if line.len() < 3 {
378 return None;
379 }
380
381 let code = &line[0..2];
382 if code == "!!" {
383 return None;
384 }
385
386 let raw_path = line[3..].trim().to_string();
387 let status = map_porcelain_status(code);
388
389 if matches!(status, FileChangeStatus::Renamed | FileChangeStatus::Copied)
390 && raw_path.contains(" -> ")
391 {
392 let parts: Vec<&str> = raw_path.splitn(2, " -> ").collect();
393 if parts.len() == 2 {
394 return Some(GitFileChange {
395 path: parts[1].to_string(),
396 previous_path: Some(parts[0].to_string()),
397 status,
398 });
399 }
400 }
401
402 Some(GitFileChange {
403 path: raw_path,
404 previous_path: None,
405 status,
406 })
407 })
408 .collect()
409}
410
411pub fn get_repo_changes(repo_path: &str) -> RepoChanges {
412 let branch = get_current_branch(repo_path).unwrap_or_else(|| "unknown".into());
413 let status = get_repo_status(repo_path);
414 let files = Command::new("git")
415 .args(["status", "--porcelain", "-uall"])
416 .current_dir(repo_path)
417 .output()
418 .ok()
419 .filter(|o| o.status.success())
420 .map(|o| parse_git_status_porcelain(&String::from_utf8_lossy(&o.stdout)))
421 .unwrap_or_default();
422
423 RepoChanges {
424 branch,
425 status,
426 files,
427 }
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize)]
431#[serde(rename_all = "camelCase")]
432pub struct ClonedRepoInfo {
433 pub name: String,
434 pub path: String,
435 pub dir_name: String,
436 pub branch: String,
437 pub branches: Vec<String>,
438 pub status: RepoStatus,
439}
440
441pub fn list_cloned_repos() -> Vec<ClonedRepoInfo> {
443 let base_dir = get_clone_base_dir();
444 if !base_dir.exists() {
445 return vec![];
446 }
447
448 let entries = match std::fs::read_dir(&base_dir) {
449 Ok(e) => e,
450 Err(_) => return vec![],
451 };
452
453 entries
454 .flatten()
455 .filter(|e| e.path().is_dir())
456 .map(|e| {
457 let full_path = e.path();
458 let dir_name = e.file_name().to_string_lossy().to_string();
459 let path_str = full_path.to_string_lossy().to_string();
460 let branch_info = get_branch_info(&path_str);
461 let repo_status = get_repo_status(&path_str);
462 ClonedRepoInfo {
463 name: dir_name_to_repo(&dir_name),
464 path: path_str,
465 dir_name,
466 branch: branch_info.current,
467 branches: branch_info.branches,
468 status: repo_status,
469 }
470 })
471 .collect()
472}
473
474pub fn discover_skills_from_path(repo_path: &Path) -> Vec<DiscoveredSkill> {
476 let dirs_to_check = [
477 "skills",
478 ".agents/skills",
479 ".opencode/skills",
480 ".claude/skills",
481 ];
482
483 let mut result = Vec::new();
484
485 for dir in &dirs_to_check {
486 let skill_dir = repo_path.join(dir);
487 if skill_dir.is_dir() {
488 scan_skill_dir(&skill_dir, &mut result);
489 }
490 }
491
492 let root_skill = repo_path.join("SKILL.md");
494 if root_skill.is_file() {
495 if let Some(skill) = parse_discovered_skill(&root_skill) {
496 result.push(skill);
497 }
498 }
499
500 result
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
504#[serde(rename_all = "camelCase")]
505pub struct DiscoveredSkill {
506 pub name: String,
507 pub description: String,
508 pub source: String,
509 #[serde(skip_serializing_if = "Option::is_none")]
510 pub license: Option<String>,
511 #[serde(skip_serializing_if = "Option::is_none")]
512 pub compatibility: Option<String>,
513}
514
515fn scan_skill_dir(dir: &Path, out: &mut Vec<DiscoveredSkill>) {
516 let entries = match std::fs::read_dir(dir) {
517 Ok(e) => e,
518 Err(_) => return,
519 };
520
521 for entry in entries.flatten() {
522 let path = entry.path();
523 if path.is_dir() {
524 let skill_file = path.join("SKILL.md");
525 if skill_file.is_file() {
526 if let Some(skill) = parse_discovered_skill(&skill_file) {
527 out.push(skill);
528 }
529 }
530 }
531 }
532}
533
534#[derive(Debug, serde::Deserialize)]
536struct SkillFrontmatter {
537 name: String,
538 description: String,
539 #[serde(default)]
540 license: Option<String>,
541 #[serde(default)]
542 compatibility: Option<String>,
543}
544
545fn parse_discovered_skill(path: &Path) -> Option<DiscoveredSkill> {
546 let content = std::fs::read_to_string(path).ok()?;
547
548 if let Some((fm_str, _body)) = extract_frontmatter_str(&content) {
550 if let Ok(fm) = serde_yaml::from_str::<SkillFrontmatter>(&fm_str) {
551 return Some(DiscoveredSkill {
552 name: fm.name,
553 description: fm.description,
554 source: path.to_string_lossy().to_string(),
555 license: fm.license,
556 compatibility: fm.compatibility,
557 });
558 }
559 }
560
561 let name = path
563 .parent()
564 .and_then(|p| p.file_name())
565 .map(|n| n.to_string_lossy().to_string())
566 .unwrap_or_else(|| "unknown".into());
567
568 let description = content
569 .lines()
570 .skip_while(|l| l.starts_with('#') || l.starts_with("---") || l.trim().is_empty())
571 .take_while(|l| !l.trim().is_empty())
572 .collect::<Vec<_>>()
573 .join(" ");
574
575 Some(DiscoveredSkill {
576 name,
577 description: if description.is_empty() {
578 "No description".into()
579 } else {
580 description
581 },
582 source: path.to_string_lossy().to_string(),
583 license: None,
584 compatibility: None,
585 })
586}
587
588#[cfg(test)]
589mod status_tests {
590 use super::{parse_git_status_porcelain, FileChangeStatus};
591
592 #[test]
593 fn parse_git_status_porcelain_maps_statuses() {
594 let output = " M src/app.ts\nA src/new.ts\nD src/old.ts\nR src/was.ts -> src/now.ts\n?? scratch.txt\nUU merge.txt\n";
595 let files = parse_git_status_porcelain(output);
596
597 assert_eq!(files.len(), 6);
598 assert_eq!(files[0].status, FileChangeStatus::Modified);
599 assert_eq!(files[1].status, FileChangeStatus::Added);
600 assert_eq!(files[2].status, FileChangeStatus::Deleted);
601 assert_eq!(files[3].status, FileChangeStatus::Renamed);
602 assert_eq!(files[3].previous_path.as_deref(), Some("src/was.ts"));
603 assert_eq!(files[3].path, "src/now.ts");
604 assert_eq!(files[4].status, FileChangeStatus::Untracked);
605 assert_eq!(files[5].status, FileChangeStatus::Conflicted);
606 }
607}
608
609fn extract_frontmatter_str(contents: &str) -> Option<(String, String)> {
611 let mut lines = contents.lines();
612 if !matches!(lines.next(), Some(line) if line.trim() == "---") {
613 return None;
614 }
615
616 let mut frontmatter_lines: Vec<&str> = Vec::new();
617 let mut body_start = false;
618 let mut body_lines: Vec<&str> = Vec::new();
619
620 for line in lines {
621 if !body_start {
622 if line.trim() == "---" {
623 body_start = true;
624 } else {
625 frontmatter_lines.push(line);
626 }
627 } else {
628 body_lines.push(line);
629 }
630 }
631
632 if frontmatter_lines.is_empty() || !body_start {
633 return None;
634 }
635
636 Some((frontmatter_lines.join("\n"), body_lines.join("\n")))
637}
638
639pub fn get_worktree_base_dir() -> PathBuf {
643 dirs::home_dir()
644 .unwrap_or_else(|| PathBuf::from("."))
645 .join(".routa")
646 .join("worktrees")
647}
648
649pub fn get_default_workspace_worktree_root(workspace_id: &str) -> PathBuf {
651 dirs::home_dir()
652 .unwrap_or_else(|| PathBuf::from("."))
653 .join(".routa")
654 .join("workspace")
655 .join(workspace_id)
656}
657
658pub fn branch_to_safe_dir_name(branch: &str) -> String {
660 branch
661 .chars()
662 .map(|c| {
663 if c.is_alphanumeric() || c == '.' || c == '_' || c == '-' {
664 c
665 } else {
666 '-'
667 }
668 })
669 .collect()
670}
671
672pub fn worktree_prune(repo_path: &str) -> Result<(), String> {
674 let output = Command::new("git")
675 .args(["worktree", "prune"])
676 .current_dir(repo_path)
677 .output()
678 .map_err(|e| e.to_string())?;
679 if output.status.success() {
680 Ok(())
681 } else {
682 Err(String::from_utf8_lossy(&output.stderr).to_string())
683 }
684}
685
686pub fn worktree_add(
688 repo_path: &str,
689 worktree_path: &str,
690 branch: &str,
691 base_branch: &str,
692 create_branch: bool,
693) -> Result<(), String> {
694 if let Some(parent) = Path::new(worktree_path).parent() {
696 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
697 }
698
699 let args = if create_branch {
700 vec![
701 "worktree".to_string(),
702 "add".to_string(),
703 "-b".to_string(),
704 branch.to_string(),
705 worktree_path.to_string(),
706 base_branch.to_string(),
707 ]
708 } else {
709 vec![
710 "worktree".to_string(),
711 "add".to_string(),
712 worktree_path.to_string(),
713 branch.to_string(),
714 ]
715 };
716
717 let output = Command::new("git")
718 .args(&args)
719 .current_dir(repo_path)
720 .output()
721 .map_err(|e| e.to_string())?;
722
723 if output.status.success() {
724 Ok(())
725 } else {
726 Err(String::from_utf8_lossy(&output.stderr).to_string())
727 }
728}
729
730pub fn worktree_remove(repo_path: &str, worktree_path: &str, force: bool) -> Result<(), String> {
732 let mut args = vec!["worktree", "remove"];
733 if force {
734 args.push("--force");
735 }
736 args.push(worktree_path);
737
738 let output = Command::new("git")
739 .args(&args)
740 .current_dir(repo_path)
741 .output()
742 .map_err(|e| e.to_string())?;
743
744 if output.status.success() {
745 Ok(())
746 } else {
747 Err(String::from_utf8_lossy(&output.stderr).to_string())
748 }
749}
750
751#[derive(Debug, Clone, Serialize, Deserialize)]
752#[serde(rename_all = "camelCase")]
753pub struct WorktreeListEntry {
754 pub path: String,
755 pub head: String,
756 pub branch: String,
757}
758
759pub fn worktree_list(repo_path: &str) -> Vec<WorktreeListEntry> {
761 let output = match Command::new("git")
762 .args(["worktree", "list", "--porcelain"])
763 .current_dir(repo_path)
764 .output()
765 {
766 Ok(o) if o.status.success() => o,
767 _ => return vec![],
768 };
769
770 let text = String::from_utf8_lossy(&output.stdout);
771 let mut entries = Vec::new();
772 let mut current_path = String::new();
773 let mut current_head = String::new();
774 let mut current_branch = String::new();
775
776 for line in text.lines() {
777 if let Some(p) = line.strip_prefix("worktree ") {
778 if !current_path.is_empty() {
779 entries.push(WorktreeListEntry {
780 path: std::mem::take(&mut current_path),
781 head: std::mem::take(&mut current_head),
782 branch: std::mem::take(&mut current_branch),
783 });
784 }
785 current_path = p.to_string();
786 } else if let Some(h) = line.strip_prefix("HEAD ") {
787 current_head = h.to_string();
788 } else if let Some(b) = line.strip_prefix("branch ") {
789 current_branch = b.strip_prefix("refs/heads/").unwrap_or(b).to_string();
791 }
792 }
793
794 if !current_path.is_empty() {
796 entries.push(WorktreeListEntry {
797 path: current_path,
798 head: current_head,
799 branch: current_branch,
800 });
801 }
802
803 entries
804}
805
806pub fn branch_exists(repo_path: &str, branch: &str) -> bool {
808 Command::new("git")
809 .args(["branch", "--list", branch])
810 .current_dir(repo_path)
811 .output()
812 .ok()
813 .filter(|o| o.status.success())
814 .map(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty())
815 .unwrap_or(false)
816}
817
818pub fn copy_dir_recursive(src: &Path, dest: &Path) -> std::io::Result<()> {
820 std::fs::create_dir_all(dest)?;
821 for entry in std::fs::read_dir(src)? {
824 let entry = entry?;
825 let src_path = entry.path();
826 let dest_path = dest.join(entry.file_name());
827
828 if src_path.is_dir() {
829 let name = entry.file_name();
830 let name_str = name.to_string_lossy();
831 if name_str == ".git" || name_str == "node_modules" {
832 continue;
833 }
834 copy_dir_recursive(&src_path, &dest_path)?;
835 } else {
836 std::fs::copy(&src_path, &dest_path)?;
837 }
838 }
839 Ok(())
840}
841
842#[cfg(test)]
843mod tests {
844 use super::*;
845 use std::fs;
846 use tempfile::tempdir;
847
848 #[test]
849 fn parse_github_url_supports_multiple_formats() {
850 let https = parse_github_url("https://github.com/phodal/routa-js.git").unwrap();
851 assert_eq!(https.owner, "phodal");
852 assert_eq!(https.repo, "routa-js");
853
854 let ssh = parse_github_url("git@github.com:owner/repo-name.git").unwrap();
855 assert_eq!(ssh.owner, "owner");
856 assert_eq!(ssh.repo, "repo-name");
857
858 let shorthand = parse_github_url("foo/bar.baz").unwrap();
859 assert_eq!(shorthand.owner, "foo");
860 assert_eq!(shorthand.repo, "bar.baz");
861
862 assert!(parse_github_url(r"C:\tmp\repo").is_none());
863 }
864
865 #[test]
866 fn repo_dir_name_conversions_are_stable() {
867 let dir = repo_to_dir_name("org", "project");
868 assert_eq!(dir, "org--project");
869 assert_eq!(dir_name_to_repo(&dir), "org/project");
870 assert_eq!(dir_name_to_repo("no-separator"), "no-separator");
871 }
872
873 #[test]
874 fn frontmatter_extraction_requires_both_delimiters() {
875 let content = "---\nname: demo\ndescription: hello\n---\nbody";
876 let (fm, body) = extract_frontmatter_str(content).unwrap();
877 assert!(fm.contains("name: demo"));
878 assert_eq!(body, "body");
879
880 assert!(extract_frontmatter_str("name: x\n---\nbody").is_none());
881 assert!(extract_frontmatter_str("---\nname: x\nbody").is_none());
882 }
883
884 #[test]
885 fn parse_discovered_skill_supports_frontmatter_and_fallback() {
886 let temp = tempdir().unwrap();
887 let skill_dir = temp.path().join("skills").join("demo");
888 fs::create_dir_all(&skill_dir).unwrap();
889
890 let fm_skill = skill_dir.join("SKILL.md");
891 fs::write(
892 &fm_skill,
893 "---\nname: Demo Skill\ndescription: Does demo things\nlicense: MIT\ncompatibility: rust\n---\n# Body\n",
894 )
895 .unwrap();
896
897 let parsed = parse_discovered_skill(&fm_skill).unwrap();
898 assert_eq!(parsed.name, "Demo Skill");
899 assert_eq!(parsed.description, "Does demo things");
900 assert_eq!(parsed.license.as_deref(), Some("MIT"));
901 assert_eq!(parsed.compatibility.as_deref(), Some("rust"));
902
903 let fallback_dir = temp.path().join("skills").join("fallback-skill");
904 fs::create_dir_all(&fallback_dir).unwrap();
905 let fallback_file = fallback_dir.join("SKILL.md");
906 fs::write(
907 &fallback_file,
908 "# Title\n\nFirst line of fallback description.\nSecond line.\n\n## Next section\n",
909 )
910 .unwrap();
911
912 let fallback = parse_discovered_skill(&fallback_file).unwrap();
913 assert_eq!(fallback.name, "fallback-skill");
914 assert_eq!(
915 fallback.description,
916 "First line of fallback description. Second line."
917 );
918 assert!(fallback.license.is_none());
919 assert!(fallback.compatibility.is_none());
920 }
921
922 #[test]
923 fn discover_skills_from_path_scans_known_locations_and_root() {
924 let temp = tempdir().unwrap();
925
926 let skill_paths = [
927 temp.path().join("skills").join("a").join("SKILL.md"),
928 temp.path()
929 .join(".agents/skills")
930 .join("b")
931 .join("SKILL.md"),
932 temp.path()
933 .join(".opencode/skills")
934 .join("c")
935 .join("SKILL.md"),
936 temp.path()
937 .join(".claude/skills")
938 .join("d")
939 .join("SKILL.md"),
940 temp.path().join("SKILL.md"),
941 ];
942
943 for path in &skill_paths {
944 fs::create_dir_all(path.parent().unwrap()).unwrap();
945 }
946
947 fs::write(
948 &skill_paths[0],
949 "---\nname: skill-a\ndescription: from skills\n---\n",
950 )
951 .unwrap();
952 fs::write(
953 &skill_paths[1],
954 "---\nname: skill-b\ndescription: from agents\n---\n",
955 )
956 .unwrap();
957 fs::write(
958 &skill_paths[2],
959 "---\nname: skill-c\ndescription: from opencode\n---\n",
960 )
961 .unwrap();
962 fs::write(
963 &skill_paths[3],
964 "---\nname: skill-d\ndescription: from claude\n---\n",
965 )
966 .unwrap();
967 fs::write(
968 &skill_paths[4],
969 "---\nname: root-skill\ndescription: from root\n---\n",
970 )
971 .unwrap();
972
973 let discovered = discover_skills_from_path(temp.path());
974 let mut names = discovered.into_iter().map(|s| s.name).collect::<Vec<_>>();
975 names.sort();
976 assert_eq!(
977 names,
978 vec![
979 "root-skill".to_string(),
980 "skill-a".to_string(),
981 "skill-b".to_string(),
982 "skill-c".to_string(),
983 "skill-d".to_string()
984 ]
985 );
986 }
987
988 #[test]
989 fn branch_to_safe_dir_name_replaces_unsafe_chars() {
990 assert_eq!(
991 branch_to_safe_dir_name("feature/new ui@2026"),
992 "feature-new-ui-2026"
993 );
994 assert_eq!(branch_to_safe_dir_name("release-1.2.3"), "release-1.2.3");
995 }
996
997 #[test]
998 fn copy_dir_recursive_skips_git_and_node_modules() {
999 let temp = tempdir().unwrap();
1000 let src = temp.path().join("src");
1001 let dest = temp.path().join("dest");
1002
1003 fs::create_dir_all(src.join(".git")).unwrap();
1004 fs::create_dir_all(src.join("node_modules/pkg")).unwrap();
1005 fs::create_dir_all(src.join("nested")).unwrap();
1006
1007 fs::write(src.join(".git/config"), "ignored").unwrap();
1008 fs::write(src.join("node_modules/pkg/index.js"), "ignored").unwrap();
1009 fs::write(src.join("nested/kept.txt"), "hello").unwrap();
1010 fs::write(src.join("root.txt"), "root").unwrap();
1011
1012 copy_dir_recursive(&src, &dest).unwrap();
1013
1014 assert!(dest.join("root.txt").is_file());
1015 assert!(dest.join("nested/kept.txt").is_file());
1016 assert!(!dest.join(".git").exists());
1017 assert!(!dest.join("node_modules").exists());
1018 }
1019}