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