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