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
495pub async fn board_reconcile(repo: Option<&str>) -> anyhow::Result<()> {
498 let repo = match repo {
499 Some(repo) => repo.to_owned(),
500 None => config::get_current_repo()?,
501 };
502 let config = crate::github::projects::label_status::LabelSyncConfig::from_config()?;
503 let gh = crate::github::http::GhHttp::new()?;
504 let updated = board_reconcile_with(&config, &gh, &repo).await?;
505 println!("Updated {updated} project Status field(s) in this pass; skipped items are reported at debug level.");
506 Ok(())
507}
508
509pub(crate) async fn board_reconcile_with(
510 config: &crate::github::projects::label_status::LabelSyncConfig,
511 api: &impl crate::github::projects::label_status::ProjectApi,
512 repo: &str,
513) -> anyhow::Result<usize> {
514 if !config.configured_for_repo(repo) {
515 anyhow::bail!("no enabled gh.project_label_sync board configured for this repository");
516 }
517 config.reconcile(api, repo).await
518}
519
520fn parse_github_slug(input: &str) -> Option<(String, String)> {
528 if input.starts_with("git@github.com:") {
530 let path = input
531 .trim_start_matches("git@github.com:")
532 .trim_end_matches('/')
533 .trim_end_matches(".git");
534 let parts: Vec<&str> = path.splitn(3, '/').collect();
535 if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
536 return Some((parts[0].to_string(), parts[1].to_string()));
537 }
538 return None;
539 }
540
541 if input.starts_with("https://github.com/") || input.starts_with("http://github.com/") {
543 let path = input
544 .trim_start_matches("https://github.com/")
545 .trim_start_matches("http://github.com/")
546 .trim_end_matches('/')
547 .trim_end_matches(".git");
548 let parts: Vec<&str> = path.splitn(3, '/').collect();
549 if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
550 return Some((parts[0].to_string(), parts[1].to_string()));
551 }
552 return None;
553 }
554
555 if input.starts_with('/')
557 || input.starts_with('.')
558 || input.starts_with('~')
559 || (input.contains(std::path::MAIN_SEPARATOR) && std::path::MAIN_SEPARATOR != '/')
560 {
561 return None;
562 }
563
564 let parts: Vec<&str> = input.splitn(3, '/').collect();
566 if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
567 let owner = parts[0];
569 let repo = parts[1].trim_end_matches(".git");
570 if !owner.contains(' ') && !repo.contains(' ') && !repo.contains('/') {
571 return Some((owner.to_string(), repo.to_string()));
572 }
573 }
574
575 None
576}
577
578pub fn project_add(input: &str) -> anyhow::Result<()> {
585 if let Some((owner, repo)) = parse_github_slug(input) {
587 return project_add_github(&owner, &repo);
588 }
589
590 project_add_local(input)
592}
593
594fn project_add_local(path: &str) -> anyhow::Result<()> {
596 let abs_path = if path == "." {
597 std::env::current_dir()?
598 } else {
599 std::path::PathBuf::from(path).canonicalize()?
600 };
601
602 let orch_yml = abs_path.join(".orch.yml");
604 if !orch_yml.exists() {
605 let legacy = abs_path.join(".orchestrator.yml");
607 if legacy.exists() {
608 println!("Found legacy .orchestrator.yml — consider renaming to .orch.yml");
609 } else {
610 anyhow::bail!(
611 "no .orch.yml found in {} — run `orch init` in the project first",
612 abs_path.display()
613 );
614 }
615 }
616
617 let path_str = abs_path.to_string_lossy().to_string();
618 register_project_path(&path_str)?;
619
620 if orch_yml.exists() {
622 let project_content = std::fs::read_to_string(&orch_yml)?;
623 let project_doc: serde_norway::Value = serde_norway::from_str(&project_content)?;
624 if let Some(repo) = project_doc
625 .get("gh")
626 .and_then(|gh| gh.get("repo"))
627 .and_then(|r| r.as_str())
628 {
629 println!(" repo: {}", repo);
630 }
631 }
632
633 Ok(())
634}
635
636fn project_add_github(owner: &str, repo: &str) -> anyhow::Result<()> {
638 let projects_dir = crate::home::projects_dir()?;
639 let bare_path = projects_dir.join(owner).join(format!("{repo}.git"));
640 let slug = format!("{owner}/{repo}");
641
642 if bare_path.exists() {
643 println!("Bare clone already exists: {}", bare_path.display());
644 } else {
645 let parent = bare_path
647 .parent()
648 .ok_or_else(|| anyhow::anyhow!("bare path has no parent: {}", bare_path.display()))?;
649 std::fs::create_dir_all(parent)?;
650
651 println!("Cloning {slug} as bare repo...");
652 let status = std::process::Command::new("gh")
653 .args([
654 "repo",
655 "clone",
656 &slug,
657 &bare_path.to_string_lossy(),
658 "--",
659 "--bare",
660 ])
661 .status_with_context()?;
662
663 if !status.success() {
664 anyhow::bail!("gh repo clone failed for {slug}");
665 }
666
667 println!("Cloned to {}", bare_path.display());
668 }
669
670 let orch_yml = bare_path.join(".orch.yml");
672 if !orch_yml.exists() {
673 let content = format!("# Project-specific orch config\ngh:\n repo: \"{slug}\"\n");
674 std::fs::write(&orch_yml, content)?;
675 println!("Created .orch.yml with gh.repo: {slug}");
676 }
677
678 let path_str = bare_path.to_string_lossy().to_string();
680 register_project_path(&path_str)?;
681 println!(" repo: {slug}");
682
683 Ok(())
684}
685
686fn register_project_path(path_str: &str) -> anyhow::Result<()> {
688 let config_path = crate::home::config_path()?;
689 let content = if config_path.exists() {
690 std::fs::read_to_string(&config_path)?
691 } else {
692 String::new()
693 };
694
695 let mut doc: serde_norway::Value = if content.is_empty() {
696 serde_norway::Value::Mapping(serde_norway::Mapping::new())
697 } else {
698 serde_norway::from_str(&content)?
699 };
700
701 let root = doc
702 .as_mapping_mut()
703 .ok_or_else(|| anyhow::anyhow!("config is not a YAML mapping"))?;
704
705 let projects_key = serde_norway::Value::String("projects".to_string());
707 if !root.contains_key(&projects_key) {
708 root.insert(
709 projects_key.clone(),
710 serde_norway::Value::Sequence(Vec::new()),
711 );
712 }
713
714 let projects = root
715 .get_mut(&projects_key)
716 .and_then(|v| v.as_sequence_mut())
717 .ok_or_else(|| anyhow::anyhow!("projects is not a list"))?;
718
719 let already_exists = projects
721 .iter()
722 .any(|p| p.as_str().map(|s| s == path_str).unwrap_or(false));
723
724 if already_exists {
725 println!("Project already registered: {}", path_str);
726 return Ok(());
727 }
728
729 projects.push(serde_norway::Value::String(path_str.to_string()));
730 std::fs::write(&config_path, serde_norway::to_string(&doc)?)?;
731
732 println!("Added project: {}", path_str);
733
734 Ok(())
735}
736
737pub fn project_remove(path: &str) -> anyhow::Result<()> {
739 let abs_path = std::path::PathBuf::from(path)
740 .canonicalize()
741 .unwrap_or_else(|_| std::path::PathBuf::from(path));
742 let path_str = abs_path.to_string_lossy().to_string();
743
744 let config_path = crate::home::config_path()?;
745 if !config_path.exists() {
746 anyhow::bail!("no global config found");
747 }
748
749 let content = std::fs::read_to_string(&config_path)?;
750 let mut doc: serde_norway::Value = serde_norway::from_str(&content)?;
751
752 let root = doc
753 .as_mapping_mut()
754 .ok_or_else(|| anyhow::anyhow!("config is not a YAML mapping"))?;
755
756 let projects_key = serde_norway::Value::String("projects".to_string());
757 let projects = root
758 .get_mut(&projects_key)
759 .and_then(|v| v.as_sequence_mut())
760 .ok_or_else(|| anyhow::anyhow!("no projects list in config"))?;
761
762 let before_len = projects.len();
763 projects.retain(|p| {
764 p.as_str()
765 .map(|s| s != path_str && s != path)
766 .unwrap_or(true)
767 });
768
769 if projects.len() == before_len {
770 println!("Project not found: {}", path);
771 return Ok(());
772 }
773
774 std::fs::write(&config_path, serde_norway::to_string(&doc)?)?;
775 println!("Removed project: {}", path_str);
776
777 Ok(())
778}
779
780pub fn project_list() -> anyhow::Result<()> {
782 let projects = config::get_project_paths()?;
783
784 if projects.is_empty() {
785 println!("No projects registered");
786 println!(" Run `orch project add <path>` to register a project");
787 return Ok(());
788 }
789
790 println!("{:<50} REPO", "PATH");
791 println!("{}", "-".repeat(80));
792
793 for path_str in &projects {
794 let path = std::path::Path::new(path_str);
795
796 let repo = read_project_repo(path).unwrap_or_else(|| "(.orch.yml not found)".to_string());
798
799 let status = if path.exists() { "" } else { " (missing)" };
800
801 println!("{:<50} {}{}", path_str, repo, status);
802 }
803
804 Ok(())
805}
806
807fn read_project_repo(project_path: &std::path::Path) -> Option<String> {
809 let orch_yml = project_path.join(".orch.yml");
810 let content = std::fs::read_to_string(&orch_yml).ok()?;
811 let doc: serde_norway::Value = serde_norway::from_str(&content).ok()?;
812 doc.get("gh")
813 .and_then(|gh| gh.get("repo"))
814 .and_then(|r| r.as_str())
815 .map(String::from)
816}
817
818pub async fn init_task_manager() -> anyhow::Result<TaskManager> {
820 use crate::backends::github::GitHubBackend;
821 use crate::backends::ExternalBackend;
822 use crate::store::TaskStore;
823
824 let repo = config::get_current_repo()
825 .context("'repo' not set — run `orch init` or set gh.repo in .orch.yml")?;
826 let backend: Arc<dyn ExternalBackend> = Arc::new(GitHubBackend::new(repo.clone())?);
827 let store = Arc::new(TaskStore::open(&crate::store::default_db_path().await?).await?);
828 Ok(TaskManager::with_store(backend, store, repo))
829}
830
831pub async fn init_store() -> anyhow::Result<crate::store::TaskStore> {
833 crate::store::TaskStore::open(&crate::store::default_db_path().await?).await
834}
835
836#[cfg(test)]
837mod tests {
838 use super::*;
839
840 #[test]
841 fn hours_to_label_prefers_days_for_full_day_windows() {
842 assert_eq!(hours_to_label(24), "1d");
843 assert_eq!(hours_to_label(168), "7d");
844 assert_eq!(hours_to_label(30), "30h");
845 }
846
847 #[test]
848 fn parse_since_supports_days_hours_and_bare_numbers() {
849 assert_eq!(parse_since("7d"), Some(168));
850 assert_eq!(parse_since("24h"), Some(24));
851 assert_eq!(parse_since("36"), Some(36));
852 assert_eq!(parse_since("bad"), None);
853 }
854
855 #[test]
856 fn parse_slug_owner_repo() {
857 let result = parse_github_slug("gabrielkoerich/orch");
858 assert_eq!(
859 result,
860 Some(("gabrielkoerich".to_string(), "orch".to_string()))
861 );
862 }
863
864 #[test]
865 fn parse_slug_https_url() {
866 let result = parse_github_slug("https://github.com/gabrielkoerich/orch");
867 assert_eq!(
868 result,
869 Some(("gabrielkoerich".to_string(), "orch".to_string()))
870 );
871 }
872
873 #[test]
874 fn parse_slug_https_url_with_git_suffix() {
875 let result = parse_github_slug("https://github.com/gabrielkoerich/orch.git");
876 assert_eq!(
877 result,
878 Some(("gabrielkoerich".to_string(), "orch".to_string()))
879 );
880 }
881
882 #[test]
883 fn parse_slug_https_url_trailing_slash() {
884 let result = parse_github_slug("https://github.com/gabrielkoerich/orch/");
885 assert_eq!(
886 result,
887 Some(("gabrielkoerich".to_string(), "orch".to_string()))
888 );
889 }
890
891 #[test]
892 fn parse_slug_absolute_path_returns_none() {
893 assert_eq!(parse_github_slug("/Users/gb/Projects/my-app"), None);
894 }
895
896 #[test]
897 fn parse_slug_relative_path_returns_none() {
898 assert_eq!(parse_github_slug("./my-app"), None);
899 }
900
901 #[test]
902 fn parse_slug_dot_returns_none() {
903 assert_eq!(parse_github_slug("."), None);
904 }
905
906 #[test]
907 fn parse_slug_tilde_path_returns_none() {
908 assert_eq!(parse_github_slug("~/Projects/my-app"), None);
909 }
910
911 #[test]
912 fn parse_slug_single_word_returns_none() {
913 assert_eq!(parse_github_slug("my-app"), None);
914 }
915
916 #[test]
917 fn parse_slug_three_segments_returns_none() {
918 assert_eq!(parse_github_slug("a/b/c"), None);
920 }
921
922 #[test]
923 fn parse_slug_ssh_url() {
924 let result = parse_github_slug("git@github.com:gabrielkoerich/bean.git");
925 assert_eq!(
926 result,
927 Some(("gabrielkoerich".to_string(), "bean".to_string()))
928 );
929 }
930
931 #[test]
932 fn parse_slug_ssh_url_no_git_suffix() {
933 let result = parse_github_slug("git@github.com:gabrielkoerich/bean");
934 assert_eq!(
935 result,
936 Some(("gabrielkoerich".to_string(), "bean".to_string()))
937 );
938 }
939}