use super::*;
use crate::session_manager::record::{ManagedSessionId, ManagedSessionState, SessionRecord};
use chrono::Utc;
use std::path::PathBuf;
fn proj(name: &str, repo_url: &str) -> Project {
Project {
name: name.to_string(),
repo_url: repo_url.to_string(),
default_branch: "main".to_string(),
stack_hint: None,
tags: vec![],
description: None,
gh_user: None,
gh_account: None,
github: None,
commit_name: None,
commit_email: None,
worktree: None,
}
}
fn proj_tagged(name: &str, repo_url: &str, tags: &[&str], desc: &str) -> Project {
Project {
name: name.to_string(),
repo_url: repo_url.to_string(),
default_branch: "main".to_string(),
stack_hint: None,
tags: tags.iter().map(|s| s.to_string()).collect(),
description: Some(desc.to_string()),
gh_user: None,
gh_account: None,
github: None,
commit_name: None,
commit_email: None,
worktree: None,
}
}
fn session_with_repo(repo_url: &str) -> SessionRecord {
SessionRecord {
id: ManagedSessionId::new(),
tmux_name: "test".into(),
cwd: PathBuf::from("/tmp"),
task: "task".into(),
state: ManagedSessionState::Active,
created_at: Utc::now(),
last_activity_at: None,
workspace_path: None,
repo_url: Some(repo_url.to_string()),
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
tmux_server: None,
injection_status: Default::default(),
worktree_owner: None,
terminal_at: None,
stop_cause: None,
kind: Default::default(),
}
}
fn session_no_repo() -> SessionRecord {
SessionRecord {
id: ManagedSessionId::new(),
tmux_name: "no-repo".into(),
cwd: PathBuf::from("/tmp"),
task: "no repo".into(),
state: ManagedSessionState::Active,
created_at: Utc::now(),
last_activity_at: None,
workspace_path: None,
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
tmux_server: None,
injection_status: Default::default(),
worktree_owner: None,
terminal_at: None,
stop_cause: None,
kind: Default::default(),
}
}
#[test]
fn resolver_exact_name() {
let projects = vec![
proj("trusty-tools", "https://github.com/org/trusty-tools"),
proj("trusty-search", "https://github.com/org/trusty-search"),
];
let res = resolve_project("trusty-tools", &projects).expect("should match");
assert_eq!(
res.primary.as_ref().expect("primary").project.name,
"trusty-tools"
);
assert_eq!(
res.primary.as_ref().expect("primary").reason,
ResolutionReason::ExactNameMatch
);
assert!((res.primary.as_ref().expect("primary").confidence - 1.0).abs() < f32::EPSILON);
}
#[test]
fn resolver_exact_name_case_insensitive() {
let projects = vec![proj("TrustyTools", "https://github.com/org/trusty-tools")];
let res = resolve_project("trustytools", &projects).expect("case-insensitive match");
assert_eq!(
res.primary.as_ref().expect("primary").reason,
ResolutionReason::ExactNameMatch
);
}
#[test]
fn resolver_exact_name_does_not_partial_match_keyword() {
let projects = vec![proj("trusty-tools", "https://github.com/org/trusty-tools")];
let res = resolve_project("trusty", &projects).expect("keyword fallback");
assert_ne!(
res.primary.as_ref().expect("primary").reason,
ResolutionReason::ExactNameMatch
);
}
#[test]
fn resolver_url_match_https() {
let projects = vec![
proj("trusty-tools", "https://github.com/org/trusty-tools"),
proj("other", "https://github.com/org/other"),
];
let res =
resolve_project("https://github.com/org/trusty-tools.git", &projects).expect("url match");
assert_eq!(
res.primary.as_ref().expect("primary").project.name,
"trusty-tools"
);
assert_eq!(
res.primary.as_ref().expect("primary").reason,
ResolutionReason::UrlMatch
);
assert!((res.primary.as_ref().expect("primary").confidence - 0.95).abs() < f32::EPSILON);
}
#[test]
fn resolver_url_match_github_fragment() {
let projects = vec![proj("my-repo", "https://github.com/owner/my-repo")];
let res = resolve_project("github.com/owner/my-repo", &projects).expect("fragment match");
assert_eq!(
res.primary.as_ref().expect("primary").project.name,
"my-repo"
);
assert_eq!(
res.primary.as_ref().expect("primary").reason,
ResolutionReason::UrlMatch
);
}
#[test]
fn resolver_url_no_match_falls_through_to_keyword() {
let projects = vec![proj("trusty-tools", "https://github.com/org/trusty-tools")];
let res = resolve_project("https://github.com/other/unrelated.git", &projects);
assert!(res.is_err());
}
#[test]
fn resolver_keyword_match_name() {
let projects = vec![
proj("trusty-search", "https://github.com/org/trusty-search"),
proj("trusty-memory", "https://github.com/org/trusty-memory"),
];
let res = resolve_project("search", &projects).expect("keyword name match");
assert_eq!(
res.primary.as_ref().expect("primary").project.name,
"trusty-search"
);
assert_eq!(
res.primary.as_ref().expect("primary").reason,
ResolutionReason::KeywordMatch
);
}
#[test]
fn resolver_keyword_match_tag() {
let projects = vec![
proj_tagged(
"trusty-search",
"https://github.com/org/trusty-search",
&["rust", "search-daemon"],
"hybrid code search",
),
proj_tagged(
"trusty-memory",
"https://github.com/org/trusty-memory",
&["rust", "memory"],
"memory palace MCP server",
),
];
let res = resolve_project("memory", &projects).expect("tag match");
assert_eq!(
res.primary.as_ref().expect("primary").project.name,
"trusty-memory"
);
}
#[test]
fn resolver_keyword_match_description() {
let projects = vec![proj_tagged(
"frontend-app",
"https://github.com/org/frontend-app",
&[],
"the best frontend application ever",
)];
let res = resolve_project("frontend", &projects).expect("description+name match");
assert_eq!(
res.primary.as_ref().expect("primary").project.name,
"frontend-app"
);
}
#[test]
fn resolver_keyword_threshold_filters_low_score() {
let projects = vec![proj("alpha", "https://github.com/org/alpha")];
let res = resolve_project("xyz unrelated words", &projects);
assert!(
res.is_err(),
"should produce NoMatch for completely unrelated query"
);
}
#[test]
fn resolver_confidence_clamped_to_one() {
let project = proj_tagged(
"search-daemon",
"https://github.com/org/search-daemon",
&["search", "daemon", "backend"],
"search daemon backend service for search",
);
let projects = vec![project];
let res = resolve_project("search daemon backend", &projects).expect("should match");
let conf = res.primary.as_ref().expect("primary").confidence;
assert!(conf <= 1.0, "confidence must be clamped to 1.0, got {conf}");
assert!(conf > 0.0, "confidence must be positive");
}
#[test]
fn resolver_multi_candidate_disambiguation() {
let projects = vec![
proj_tagged(
"trusty-search",
"https://github.com/org/trusty-search",
&["rust", "search"],
"search daemon",
),
proj_tagged(
"trusty-tools",
"https://github.com/org/trusty-tools",
&["rust", "workspace"],
"the trusty search toolset",
),
];
let res = resolve_project("trusty search", &projects).expect("multiple matches");
assert!(res.matches.len() > 1, "should return multiple candidates");
assert!(
res.needs_disambiguation(),
"both candidates above disambiguation floor should trigger disambiguation"
);
let primary_conf = res.primary.as_ref().expect("primary").confidence;
for m in &res.matches {
assert!(
m.confidence <= primary_conf + f32::EPSILON,
"matches must be sorted descending"
);
}
}
#[test]
fn resolver_single_above_floor_no_disambiguation() {
let projects = vec![
proj_tagged(
"trusty-search",
"https://github.com/org/trusty-search",
&["search"],
"search daemon",
),
proj("other-project", "https://github.com/org/other"),
];
let res = resolve_project("search", &projects).expect("one match");
assert!(
!res.needs_disambiguation(),
"single candidate above floor must not require disambiguation"
);
}
#[test]
fn resolver_no_match_returns_error() {
let projects = vec![proj("alpha", "https://github.com/org/alpha")];
let err = resolve_project("completely-unrelated-xyz", &projects).unwrap_err();
assert!(
matches!(err, ResolverError::NoMatch { .. }),
"expected NoMatch, got: {err:?}"
);
}
#[test]
fn resolver_empty_registry() {
let err = resolve_project("anything", &[]).unwrap_err();
assert!(
matches!(err, ResolverError::EmptyRegistry),
"expected EmptyRegistry, got: {err:?}"
);
}
#[test]
fn resolver_ticket_keyword_reason() {
let projects = vec![proj_tagged(
"my-proj",
"https://github.com/org/my-proj",
&["proj"],
"project",
)];
let res = resolve_project("PROJ-123", &projects).expect("ticket keyword");
assert_eq!(
res.primary.as_ref().expect("primary").reason,
ResolutionReason::TicketKeyword
);
}
#[test]
fn resolver_session_project_binding() {
let projects = vec![
proj("trusty-tools", "https://github.com/org/trusty-tools"),
proj("other", "https://github.com/org/other"),
];
let session = session_with_repo("https://github.com/org/trusty-tools");
let bound = resolve_session_project(&session, &projects).expect("should bind");
assert_eq!(bound.name, "trusty-tools");
}
#[test]
fn resolver_by_repo_url_matches() {
let projects = vec![
proj("trusty-tools", "https://github.com/org/trusty-tools"),
proj("other", "https://github.com/org/other"),
];
let bound = resolve_project_by_repo_url("https://github.com/org/trusty-tools", &projects)
.expect("should match");
assert_eq!(bound.name, "trusty-tools");
}
#[test]
fn resolver_by_repo_url_no_match() {
let projects = vec![proj("trusty-tools", "https://github.com/org/trusty-tools")];
assert!(
resolve_project_by_repo_url("https://github.com/org/unregistered", &projects).is_none()
);
}
#[test]
fn resolver_session_binding_strips_git_suffix() {
let projects = vec![proj("trusty-tools", "https://github.com/org/trusty-tools")];
let session = session_with_repo("https://github.com/org/trusty-tools.git");
let bound = resolve_session_project(&session, &projects).expect("git-suffix match");
assert_eq!(bound.name, "trusty-tools");
}
#[test]
fn resolver_session_no_repo_returns_none() {
let projects = vec![proj("trusty-tools", "https://github.com/org/trusty-tools")];
let session = session_no_repo();
assert!(
resolve_session_project(&session, &projects).is_none(),
"session with no repo_url must return None"
);
}
#[test]
fn resolver_session_unregistered_url_returns_none() {
let projects = vec![proj("trusty-tools", "https://github.com/org/trusty-tools")];
let session = session_with_repo("https://github.com/other/unrelated");
assert!(
resolve_session_project(&session, &projects).is_none(),
"unregistered repo URL must return None"
);
}
#[test]
fn resolver_session_url_tie_break_first_wins() {
let projects = vec![
proj("alpha", "https://github.com/org/shared-repo"),
proj("beta", "https://github.com/org/shared-repo"),
];
let session = session_with_repo("https://github.com/org/shared-repo");
let bound = resolve_session_project(&session, &projects).expect("tie-break");
assert_eq!(
bound.name, "alpha",
"tie-break: first project in registry order wins"
);
}
#[test]
fn resolver_fleet_by_project_basic() {
let projects = vec![
proj("trusty-tools", "https://github.com/org/trusty-tools"),
proj("trusty-search", "https://github.com/org/trusty-search"),
];
let sessions = vec![
session_with_repo("https://github.com/org/trusty-tools"),
session_with_repo("https://github.com/org/trusty-tools"),
session_with_repo("https://github.com/org/trusty-search"),
];
let fleet = fleet_by_project(&sessions, &projects);
assert_eq!(fleet.len(), 2, "one fleet entry per project");
let tools_fleet = fleet
.iter()
.find(|f| f.project.name == "trusty-tools")
.expect("tools");
assert_eq!(tools_fleet.sessions.len(), 2);
let search_fleet = fleet
.iter()
.find(|f| f.project.name == "trusty-search")
.expect("search");
assert_eq!(search_fleet.sessions.len(), 1);
}
#[test]
fn resolver_fleet_empty_sessions() {
let projects = vec![proj("trusty-tools", "https://github.com/org/trusty-tools")];
let fleet = fleet_by_project(&[], &projects);
assert_eq!(fleet.len(), 1, "project with no sessions still appears");
assert!(fleet[0].sessions.is_empty());
}
#[test]
fn resolver_fleet_no_repo_sessions_omitted() {
let projects = vec![proj("trusty-tools", "https://github.com/org/trusty-tools")];
let sessions = vec![
session_no_repo(),
session_with_repo("https://github.com/other/unregistered"),
];
let fleet = fleet_by_project(&sessions, &projects);
assert_eq!(
fleet[0].sessions.len(),
0,
"unregistered/no-repo sessions omitted"
);
}
#[test]
fn resolver_fleet_empty_projects() {
let sessions = vec![session_with_repo("https://github.com/org/x")];
let fleet = fleet_by_project(&sessions, &[]);
assert!(fleet.is_empty(), "empty projects yields empty fleet");
}
#[test]
fn resolver_reason_label() {
assert!(!ResolutionReason::ExactNameMatch.label().is_empty());
assert!(!ResolutionReason::UrlMatch.label().is_empty());
assert!(!ResolutionReason::KeywordMatch.label().is_empty());
assert!(!ResolutionReason::TicketKeyword.label().is_empty());
}
#[test]
fn local_checkout_for_derives_the_managed_clone_path() {
let project = proj("cto", "https://github.com/bob-duetto/cto");
assert_eq!(
local_checkout_for(&project),
Some(
crate::daemon::managed_routes::inproject::repos_root()
.join("bob-duetto")
.join("cto")
)
);
assert_eq!(
local_checkout_for(&proj("cto", "git@github.com:bob-duetto/cto.git")),
local_checkout_for(&project)
);
}
#[test]
fn local_checkout_for_keeps_an_absolute_repo_url() {
assert_eq!(
local_checkout_for(&proj("tools", "/Users/me/work/trusty-tools")),
Some(PathBuf::from("/Users/me/work/trusty-tools"))
);
}
#[test]
fn local_checkout_for_refuses_what_names_no_project() {
assert_eq!(local_checkout_for(&proj("empty", "")), None);
assert_eq!(
local_checkout_for(&proj("host-only", "https://github.com/")),
None
);
assert_eq!(local_checkout_for(&proj("no-owner", "cto")), None);
}