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