1pub mod chat;
2pub mod commit;
3pub mod cooldown;
4pub mod cost;
5pub mod dashboard;
6pub mod doctor;
7pub mod events;
8pub mod job;
9pub mod ndjson;
10pub mod nodes;
11pub mod notify;
12pub mod plan;
13pub mod progress;
14pub mod prune;
15pub mod service;
16pub mod session;
17pub mod stats;
18pub mod stream;
19pub mod task;
20pub mod tree;
21pub mod webhook;
22
23use crate::cmd::SyncCommandErrorContext;
24use crate::config;
25use crate::engine::tasks::TaskManager;
26use anyhow::Context;
27use std::sync::Arc;
28
29pub fn init(repo: Option<String>) -> anyhow::Result<()> {
31 let orch_home = crate::home::orch_home()?;
32 std::fs::create_dir_all(&orch_home)?;
33
34 let config_path = orch_home.join("config.yml");
35
36 let repo_value = match repo {
37 Some(r) => {
38 if let Some((owner, name)) = parse_github_slug(&r) {
40 format!("{}/{}", owner, name)
41 } else {
42 r
43 }
44 }
45 None => {
46 let git_remote = std::process::Command::new("git")
48 .args(["remote", "get-url", "origin"])
49 .output_with_context();
50
51 match git_remote {
52 Ok(o) if o.status.success() => {
53 let url = String::from_utf8_lossy(&o.stdout).trim().to_string();
54 if let Some((owner, repo)) = parse_github_slug(&url) {
55 format!("{}/{}", owner, repo)
56 } else {
57 eprintln!("Could not parse repository from git remote: {}", url);
58 eprintln!("Use --repo OWNER/REPO");
59 std::process::exit(1);
60 }
61 }
62 _ => {
63 eprintln!("Could not detect repository. Use --repo OWNER/REPO");
64 std::process::exit(1);
65 }
66 }
67 }
68 };
69
70 if !config_path.exists() {
72 let content = "# Orch global configuration\n# See: https://github.com/gabrielkoerich/orch\n\nprojects: []\n\nrouter:\n mode: llm\n agent: claude\n model: haiku\n";
73 std::fs::write(&config_path, content)?;
74 }
75
76 println!("Initialized orch for {repo_value}");
77 println!("Global config: {}", config_path.display());
78
79 let local_config = std::path::Path::new(".orch.yml");
81 if !local_config.exists() {
82 std::fs::write(
83 local_config,
84 format!("# Project-specific orch config\ngh:\n repo: \"{repo_value}\"\n"),
85 )?;
86 println!("Created .orch.yml");
87 }
88
89 let cwd = std::env::current_dir()?;
91 let cwd_str = cwd.to_string_lossy().to_string();
92
93 let paths = config::get_project_paths().unwrap_or_default();
95 if !paths.iter().any(|p| p == &cwd_str) {
96 project_add(".")?;
97 } else {
98 println!("Project already registered in global config");
99 }
100
101 println!();
103 println!("Next steps:");
104 println!(" orch project board list — find GitHub Projects V2 boards");
105 println!(" orch project board link <id> — link a board for status tracking");
106
107 Ok(())
108}
109
110pub fn log(lines: &str) -> anyhow::Result<()> {
112 let state_dir = crate::home::state_dir().unwrap_or_default();
113 let brew_prefix = std::env::var("HOMEBREW_PREFIX").unwrap_or_else(|_| "/opt/homebrew".into());
114
115 let mut log_files = Vec::new();
116
117 let candidates = [
118 state_dir.join("orch.log"),
119 state_dir.join("orch.error.log"),
120 std::path::PathBuf::from(&brew_prefix).join("var/log/orch.log"),
121 std::path::PathBuf::from(&brew_prefix).join("var/log/orch.error.log"),
122 ];
123
124 for path in &candidates {
125 if path.exists()
126 && std::fs::metadata(path)
127 .map(|m| m.len() > 0)
128 .unwrap_or(false)
129 {
130 log_files.push(path.clone());
131 } else {
132 for n in 1..=5 {
134 let rotated = std::path::PathBuf::from(format!("{}.{}", path.display(), n));
135 if rotated.exists()
136 && std::fs::metadata(&rotated)
137 .map(|m| m.len() > 0)
138 .unwrap_or(false)
139 {
140 log_files.push(rotated);
141 break;
142 }
143 }
144 }
145 }
146
147 if log_files.is_empty() {
148 println!("No log files found");
149 return Ok(());
150 }
151
152 if lines == "watch" {
153 let args: Vec<String> = std::iter::once("-f".to_string())
154 .chain(log_files.iter().map(|p| p.to_string_lossy().to_string()))
155 .collect();
156 let status = std::process::Command::new("tail")
157 .args(&args)
158 .status_with_context()?;
159 std::process::exit(status.code().unwrap_or(1));
160 } else {
161 let n = lines.parse::<usize>().unwrap_or(50);
162 for path in &log_files {
163 let filename = path.file_name().unwrap_or_default().to_string_lossy();
164 println!("=== {filename} ===");
165
166 let content = std::fs::read_to_string(path)?;
167 let all_lines: Vec<&str> = content.lines().collect();
168 let start = if all_lines.len() > n {
169 all_lines.len() - n
170 } else {
171 0
172 };
173 for line in &all_lines[start..] {
174 println!("{line}");
175 }
176 println!();
177 }
178 }
179
180 Ok(())
181}
182
183pub fn agents() {
185 let agents = crate::engine::configured_agents();
186
187 println!("{:<12} {:<10} PATH", "AGENT", "STATUS");
188 println!("{}", "-".repeat(60));
189
190 for agent in &agents {
191 match which::which(agent) {
192 Ok(path) => {
193 let version = std::process::Command::new(agent)
195 .arg("--version")
196 .output_with_context()
197 .ok()
198 .and_then(|o| {
199 if o.status.success() {
200 Some(
201 String::from_utf8_lossy(&o.stdout)
202 .lines()
203 .next()
204 .unwrap_or("")
205 .trim()
206 .to_string(),
207 )
208 } else {
209 None
210 }
211 })
212 .unwrap_or_default();
213
214 let info = if version.is_empty() {
215 path.display().to_string()
216 } else {
217 format!("{} ({})", path.display(), version)
218 };
219 println!("{:<12} {:<10} {}", agent, "installed", info);
220 }
221 Err(_) => {
222 println!("{:<12} {:<10} ", agent, "missing");
223 }
224 }
225 }
226}
227
228pub async fn metrics(details: bool, since_hours: u32) -> anyhow::Result<()> {
232 let store = init_store().await?;
233
234 let summary = store.get_metrics_summary(since_hours).await?;
235 let window_label = hours_to_label(since_hours);
236
237 println!();
238 let header = format!("Orch Metrics (Last {})", window_label);
239 let width = 58usize;
240 let pad = width.saturating_sub(header.len() + 2);
241 let left_pad = pad / 2;
242 let right_pad = pad - left_pad;
243 println!("╔{}╗", "═".repeat(width));
244 println!(
245 "║{}{header}{}║",
246 " ".repeat(left_pad),
247 " ".repeat(right_pad)
248 );
249 println!("╚{}╝", "═".repeat(width));
250 println!();
251
252 println!(" Tasks:");
254 println!(" {:>6} completed", summary.tasks_completed_24h);
255 println!(" {:>6} failed", summary.tasks_failed_24h);
256 println!();
257
258 println!(" Average Duration by Complexity:");
260 if let Some(d) = summary.avg_duration_simple {
261 println!(" {:>6.1}s (simple)", d);
262 } else {
263 println!(" {:>6} (simple)", "-");
264 }
265 if let Some(d) = summary.avg_duration_medium {
266 println!(" {:>6.1}s (medium)", d);
267 } else {
268 println!(" {:>6} (medium)", "-");
269 }
270 if let Some(d) = summary.avg_duration_complex {
271 println!(" {:>6.1}s (complex)", d);
272 } else {
273 println!(" {:>6} (complex)", "-");
274 }
275 println!();
276
277 if !summary.agent_stats.is_empty() {
279 println!(" Agent Success Rates:");
280 for stat in &summary.agent_stats {
281 println!(
282 " {:<12} {:>4} runs, {:>5.1}% success",
283 stat.agent, stat.total_runs, stat.success_rate
284 );
285 }
286 println!();
287 }
288
289 println!(" Rate Limit Events: {:>6}", summary.rate_limits_24h);
291 println!();
292
293 if details {
294 let slow = store.get_slow_tasks(since_hours).await?;
296 if !slow.is_empty() {
297 println!(" Slowest Tasks (Last {}):", window_label);
298 println!(
299 " {:<20} {:<12} {:<10} DURATION",
300 "TASK ID", "AGENT", "COMPLEXITY"
301 );
302 println!(" {}", "-".repeat(56));
303 for t in &slow {
304 println!(
305 " {:<20} {:<12} {:<10} {:.1}s",
306 t.task_id,
307 t.agent,
308 t.complexity.as_deref().unwrap_or("-"),
309 t.duration_seconds
310 );
311 }
312 println!();
313 }
314
315 let errors = store.get_error_distribution(since_hours).await?;
317 if !errors.is_empty() {
318 println!(" Error Distribution (Last {}):", window_label);
319 println!(" {:<30} {:>6}", "ERROR TYPE", "COUNT");
320 println!(" {}", "-".repeat(38));
321 for e in &errors {
322 println!(
323 " {:<30} {:>6}",
324 e.error_type.as_deref().unwrap_or("unknown"),
325 e.count
326 );
327 }
328 println!();
329 } else {
330 println!(" Error Distribution (Last {}): none", window_label);
331 println!();
332 }
333
334 let review_loops = store.get_high_review_cycle_tasks(since_hours).await?;
336 if !review_loops.is_empty() {
337 println!(" Tasks with Repeated Review Loops (Last {}):", window_label);
338 println!(" {:<10} {:<12} TITLE", "CYCLES", "AGENT");
339 println!(" {}", "-".repeat(60));
340 for t in &review_loops {
341 println!(
342 " {:<10} {:<12} {}",
343 t.review_cycles,
344 t.agent.as_deref().unwrap_or("-"),
345 t.title,
346 );
347 }
348 println!();
349 }
350 }
351
352 Ok(())
353}
354
355pub fn hours_to_label(hours: u32) -> String {
357 if hours.is_multiple_of(24) {
358 format!("{}d", hours / 24)
359 } else {
360 format!("{hours}h")
361 }
362}
363
364pub fn parse_since(s: &str) -> Option<u32> {
367 if let Some(d) = s.strip_suffix('d') {
368 d.parse::<u32>().ok().map(|n| n * 24)
369 } else if let Some(h) = s.strip_suffix('h') {
370 h.parse::<u32>().ok()
371 } else {
372 s.parse::<u32>().ok()
374 }
375}
376
377pub async fn board_list() -> anyhow::Result<()> {
379 use crate::github::projects::ProjectSync;
380
381 let projects = ProjectSync::list_projects().await?;
382
383 if projects.is_empty() {
384 println!("No GitHub Projects V2 boards found");
385 return Ok(());
386 }
387
388 println!("{:<50} {:<8} ID", "TITLE", "NUMBER");
389 println!("{}", "-".repeat(80));
390 for p in &projects {
391 println!("{:<50} #{:<7} {}", p.title, p.number, p.id);
392 }
393
394 Ok(())
395}
396
397pub async fn board_link(project_id: &str) -> anyhow::Result<()> {
399 use crate::github::projects::{write_project_config, ProjectSync};
400
401 println!("Discovering board fields...");
402 let sync = ProjectSync::discover_fields(project_id).await?;
403
404 write_project_config(&sync).await?;
405
406 println!("Linked board: {}", project_id);
407 println!("Status field: {}", sync.status_field_id());
408 println!("Column mappings:");
409 for (col, opt_id) in sync.status_map() {
410 println!(" {col}: {opt_id}");
411 }
412
413 Ok(())
414}
415
416pub async fn board_sync() -> anyhow::Result<()> {
418 use crate::github::projects::{write_project_config, ProjectSync};
419
420 let project_id = config::get("gh.project_id").map_err(|_| {
421 anyhow::anyhow!("no board configured — run `orch project board link <id>` first")
422 })?;
423
424 if project_id.is_empty() {
425 anyhow::bail!("no board configured — run `orch project board link <id>` first");
426 }
427
428 println!("Syncing board fields for {}...", project_id);
429 let sync = ProjectSync::discover_fields(&project_id).await?;
430
431 write_project_config(&sync).await?;
432
433 println!("Updated config:");
434 println!(" Status field: {}", sync.status_field_id());
435 for (col, opt_id) in sync.status_map() {
436 println!(" {col}: {opt_id}");
437 }
438
439 Ok(())
440}
441
442pub fn board_info() -> anyhow::Result<()> {
444 let project_id = config::get("gh.project_id").unwrap_or_default();
445
446 if project_id.is_empty() {
447 println!("No board configured");
448 println!(" Run `orch project board list` to see available boards");
449 println!(" Run `orch project board link <id>` to link one");
450 return Ok(());
451 }
452
453 println!("Board ID: {}", project_id);
454
455 if let Ok(field_id) = config::get("gh.project_status_field_id") {
456 println!("Status field: {}", field_id);
457 }
458
459 for col in &["backlog", "in_progress", "review", "done"] {
460 if let Ok(opt_id) = config::get(&format!("gh.project_status_map.{col}")) {
461 println!(" {col}: {opt_id}");
462 }
463 }
464
465 Ok(())
466}
467
468pub async fn board_reconcile(repo: Option<&str>) -> anyhow::Result<()> {
471 let repo = match repo {
472 Some(repo) => repo.to_owned(),
473 None => config::get_current_repo()?,
474 };
475 let config = crate::github::projects::label_status::LabelSyncConfig::from_config()?;
476 let gh = crate::github::http::GhHttp::new()?;
477 let updated = board_reconcile_with(&config, &gh, &repo).await?;
478 println!("Updated {updated} project Status field(s) in this pass; skipped items are reported at debug level.");
479 Ok(())
480}
481
482pub(crate) async fn board_reconcile_with(
483 config: &crate::github::projects::label_status::LabelSyncConfig,
484 api: &impl crate::github::projects::label_status::ProjectApi,
485 repo: &str,
486) -> anyhow::Result<usize> {
487 if !config.configured_for_repo(repo) {
488 anyhow::bail!("no enabled gh.project_label_sync board configured for this repository");
489 }
490 config.reconcile(api, repo).await
491}
492
493fn parse_github_slug(input: &str) -> Option<(String, String)> {
501 if input.starts_with("git@github.com:") {
503 let path = input
504 .trim_start_matches("git@github.com:")
505 .trim_end_matches('/')
506 .trim_end_matches(".git");
507 let parts: Vec<&str> = path.splitn(3, '/').collect();
508 if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
509 return Some((parts[0].to_string(), parts[1].to_string()));
510 }
511 return None;
512 }
513
514 if input.starts_with("https://github.com/") || input.starts_with("http://github.com/") {
516 let path = input
517 .trim_start_matches("https://github.com/")
518 .trim_start_matches("http://github.com/")
519 .trim_end_matches('/')
520 .trim_end_matches(".git");
521 let parts: Vec<&str> = path.splitn(3, '/').collect();
522 if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
523 return Some((parts[0].to_string(), parts[1].to_string()));
524 }
525 return None;
526 }
527
528 if input.starts_with('/')
530 || input.starts_with('.')
531 || input.starts_with('~')
532 || (input.contains(std::path::MAIN_SEPARATOR) && std::path::MAIN_SEPARATOR != '/')
533 {
534 return None;
535 }
536
537 let parts: Vec<&str> = input.splitn(3, '/').collect();
539 if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
540 let owner = parts[0];
542 let repo = parts[1].trim_end_matches(".git");
543 if !owner.contains(' ') && !repo.contains(' ') && !repo.contains('/') {
544 return Some((owner.to_string(), repo.to_string()));
545 }
546 }
547
548 None
549}
550
551pub fn project_add(input: &str) -> anyhow::Result<()> {
558 if let Some((owner, repo)) = parse_github_slug(input) {
560 return project_add_github(&owner, &repo);
561 }
562
563 project_add_local(input)
565}
566
567fn project_add_local(path: &str) -> anyhow::Result<()> {
569 let abs_path = if path == "." {
570 std::env::current_dir()?
571 } else {
572 std::path::PathBuf::from(path).canonicalize()?
573 };
574
575 let orch_yml = abs_path.join(".orch.yml");
577 if !orch_yml.exists() {
578 let legacy = abs_path.join(".orchestrator.yml");
580 if legacy.exists() {
581 println!("Found legacy .orchestrator.yml — consider renaming to .orch.yml");
582 } else {
583 anyhow::bail!(
584 "no .orch.yml found in {} — run `orch init` in the project first",
585 abs_path.display()
586 );
587 }
588 }
589
590 let path_str = abs_path.to_string_lossy().to_string();
591 register_project_path(&path_str)?;
592
593 if orch_yml.exists() {
595 let project_content = std::fs::read_to_string(&orch_yml)?;
596 let project_doc: serde_norway::Value = serde_norway::from_str(&project_content)?;
597 if let Some(repo) = project_doc
598 .get("gh")
599 .and_then(|gh| gh.get("repo"))
600 .and_then(|r| r.as_str())
601 {
602 println!(" repo: {}", repo);
603 }
604 }
605
606 Ok(())
607}
608
609fn project_add_github(owner: &str, repo: &str) -> anyhow::Result<()> {
611 let projects_dir = crate::home::projects_dir()?;
612 let bare_path = projects_dir.join(owner).join(format!("{repo}.git"));
613 let slug = format!("{owner}/{repo}");
614
615 if bare_path.exists() {
616 println!("Bare clone already exists: {}", bare_path.display());
617 } else {
618 let parent = bare_path
620 .parent()
621 .ok_or_else(|| anyhow::anyhow!("bare path has no parent: {}", bare_path.display()))?;
622 std::fs::create_dir_all(parent)?;
623
624 println!("Cloning {slug} as bare repo...");
625 let status = std::process::Command::new("gh")
626 .args([
627 "repo",
628 "clone",
629 &slug,
630 &bare_path.to_string_lossy(),
631 "--",
632 "--bare",
633 ])
634 .status_with_context()?;
635
636 if !status.success() {
637 anyhow::bail!("gh repo clone failed for {slug}");
638 }
639
640 println!("Cloned to {}", bare_path.display());
641 }
642
643 let orch_yml = bare_path.join(".orch.yml");
645 if !orch_yml.exists() {
646 let content = format!("# Project-specific orch config\ngh:\n repo: \"{slug}\"\n");
647 std::fs::write(&orch_yml, content)?;
648 println!("Created .orch.yml with gh.repo: {slug}");
649 }
650
651 let path_str = bare_path.to_string_lossy().to_string();
653 register_project_path(&path_str)?;
654 println!(" repo: {slug}");
655
656 Ok(())
657}
658
659fn register_project_path(path_str: &str) -> anyhow::Result<()> {
661 let config_path = crate::home::config_path()?;
662 let content = if config_path.exists() {
663 std::fs::read_to_string(&config_path)?
664 } else {
665 String::new()
666 };
667
668 match config::projects_yaml::add_project(&content, path_str)? {
669 config::projects_yaml::ProjectsEdit::Unchanged => {
670 println!("Project already registered: {}", path_str);
671 }
672 config::projects_yaml::ProjectsEdit::Updated(updated) => {
673 std::fs::write(&config_path, updated)?;
674 println!("Added project: {}", path_str);
675 }
676 }
677
678 Ok(())
679}
680
681pub fn project_remove(path: &str) -> anyhow::Result<()> {
683 let abs_path = std::path::PathBuf::from(path)
684 .canonicalize()
685 .unwrap_or_else(|_| std::path::PathBuf::from(path));
686 let path_str = abs_path.to_string_lossy().to_string();
687
688 let config_path = crate::home::config_path()?;
689 if !config_path.exists() {
690 anyhow::bail!("no global config found");
691 }
692
693 let content = std::fs::read_to_string(&config_path)?;
694 match config::projects_yaml::remove_project(&content, &[&path_str, path])? {
695 config::projects_yaml::ProjectsEdit::Unchanged => {
696 println!("Project not found: {}", path);
697 }
698 config::projects_yaml::ProjectsEdit::Updated(updated) => {
699 std::fs::write(&config_path, updated)?;
700 println!("Removed project: {}", path_str);
701 }
702 }
703
704 Ok(())
705}
706
707pub fn project_list() -> anyhow::Result<()> {
709 let projects = config::get_project_paths()?;
710
711 if projects.is_empty() {
712 println!("No projects registered");
713 println!(" Run `orch project add <path>` to register a project");
714 return Ok(());
715 }
716
717 println!("{:<50} REPO", "PATH");
718 println!("{}", "-".repeat(80));
719
720 for path_str in &projects {
721 let path = std::path::Path::new(path_str);
722
723 let repo = read_project_repo(path).unwrap_or_else(|| "(.orch.yml not found)".to_string());
725
726 let status = if path.exists() { "" } else { " (missing)" };
727
728 println!("{:<50} {}{}", path_str, repo, status);
729 }
730
731 Ok(())
732}
733
734pub async fn project_move(old: &str, new: &str) -> anyhow::Result<()> {
736 for slug in [old, new] {
737 if parse_github_slug(slug).is_none() {
738 anyhow::bail!("invalid slug {slug:?}, expected owner/repo");
739 }
740 }
741 let store = init_store().await?;
742 let moved = store.move_repo(old, new).await?;
743 println!("Moved {moved} task(s) from {old} to {new}");
744 println!("Now set gh.repo: {new} in the project's .orch.yml");
745 Ok(())
746}
747
748fn read_project_repo(project_path: &std::path::Path) -> Option<String> {
750 let orch_yml = project_path.join(".orch.yml");
751 let content = std::fs::read_to_string(&orch_yml).ok()?;
752 let doc: serde_norway::Value = serde_norway::from_str(&content).ok()?;
753 doc.get("gh")
754 .and_then(|gh| gh.get("repo"))
755 .and_then(|r| r.as_str())
756 .map(String::from)
757}
758
759pub async fn init_task_manager() -> anyhow::Result<TaskManager> {
761 use crate::backends::github::GitHubBackend;
762 use crate::backends::ExternalBackend;
763
764 let repo = config::get_current_repo()
765 .context("'repo' not set — run `orch init` or set gh.repo in .orch.yml")?;
766 let backend: Arc<dyn ExternalBackend> = Arc::new(GitHubBackend::new(repo.clone())?);
767 let store = Arc::new(init_store().await?);
768 Ok(TaskManager::with_store(backend, store, repo))
769}
770
771pub async fn init_store() -> anyhow::Result<crate::store::TaskStore> {
773 let store = crate::store::TaskStore::open(&crate::store::default_db_path().await?).await?;
774 if let Some(endpoint) = crate::cluster::local::daemon_endpoint().await {
775 store.attach_daemon(endpoint);
776 }
777
778 Ok(store)
779}
780
781#[cfg(test)]
782mod tests {
783 use super::*;
784
785 #[test]
786 fn hours_to_label_prefers_days_for_full_day_windows() {
787 assert_eq!(hours_to_label(24), "1d");
788 assert_eq!(hours_to_label(168), "7d");
789 assert_eq!(hours_to_label(30), "30h");
790 }
791
792 #[test]
793 fn parse_since_supports_days_hours_and_bare_numbers() {
794 assert_eq!(parse_since("7d"), Some(168));
795 assert_eq!(parse_since("24h"), Some(24));
796 assert_eq!(parse_since("36"), Some(36));
797 assert_eq!(parse_since("bad"), None);
798 }
799
800 #[test]
801 fn parse_slug_owner_repo() {
802 let result = parse_github_slug("gabrielkoerich/orch");
803 assert_eq!(
804 result,
805 Some(("gabrielkoerich".to_string(), "orch".to_string()))
806 );
807 }
808
809 #[test]
810 fn parse_slug_https_url() {
811 let result = parse_github_slug("https://github.com/gabrielkoerich/orch");
812 assert_eq!(
813 result,
814 Some(("gabrielkoerich".to_string(), "orch".to_string()))
815 );
816 }
817
818 #[test]
819 fn parse_slug_https_url_with_git_suffix() {
820 let result = parse_github_slug("https://github.com/gabrielkoerich/orch.git");
821 assert_eq!(
822 result,
823 Some(("gabrielkoerich".to_string(), "orch".to_string()))
824 );
825 }
826
827 #[test]
828 fn parse_slug_https_url_trailing_slash() {
829 let result = parse_github_slug("https://github.com/gabrielkoerich/orch/");
830 assert_eq!(
831 result,
832 Some(("gabrielkoerich".to_string(), "orch".to_string()))
833 );
834 }
835
836 #[test]
837 fn parse_slug_absolute_path_returns_none() {
838 assert_eq!(parse_github_slug("/Users/gb/Projects/my-app"), None);
839 }
840
841 #[test]
842 fn parse_slug_relative_path_returns_none() {
843 assert_eq!(parse_github_slug("./my-app"), None);
844 }
845
846 #[test]
847 fn parse_slug_dot_returns_none() {
848 assert_eq!(parse_github_slug("."), None);
849 }
850
851 #[test]
852 fn parse_slug_tilde_path_returns_none() {
853 assert_eq!(parse_github_slug("~/Projects/my-app"), None);
854 }
855
856 #[test]
857 fn parse_slug_single_word_returns_none() {
858 assert_eq!(parse_github_slug("my-app"), None);
859 }
860
861 #[test]
862 fn parse_slug_three_segments_returns_none() {
863 assert_eq!(parse_github_slug("a/b/c"), None);
865 }
866
867 #[test]
868 fn parse_slug_ssh_url() {
869 let result = parse_github_slug("git@github.com:gabrielkoerich/bean.git");
870 assert_eq!(
871 result,
872 Some(("gabrielkoerich".to_string(), "bean".to_string()))
873 );
874 }
875
876 #[test]
877 fn parse_slug_ssh_url_no_git_suffix() {
878 let result = parse_github_slug("git@github.com:gabrielkoerich/bean");
879 assert_eq!(
880 result,
881 Some(("gabrielkoerich".to_string(), "bean".to_string()))
882 );
883 }
884}