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