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