1use crate::models::{CheckoutBinding, ProjectContext, RepositoryFingerprint};
2use crate::project_metadata;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6pub fn discover_project_context(current_directory: &Path) -> ProjectContext {
7 let local_root = git_output(current_directory, ["rev-parse", "--show-toplevel"])
8 .map(PathBuf::from)
9 .unwrap_or_else(|| current_directory.to_path_buf());
10 let remote_url = git_output(&local_root, ["config", "--get", "remote.origin.url"]);
11 let branch = git_output(&local_root, ["rev-parse", "--abbrev-ref", "HEAD"]);
12 let last_commit = git_output(&local_root, ["rev-parse", "HEAD"]);
13 let default_branch = git_output(&local_root, ["symbolic-ref", "refs/remotes/origin/HEAD"])
14 .and_then(|value| value.rsplit('/').next().map(ToString::to_string));
15 let parsed_remote = remote_url.as_deref().and_then(parse_remote_url);
16 let project_slug = parsed_remote
17 .as_ref()
18 .map(|(_, _, repo_name)| repo_name.clone())
19 .or_else(|| fallback_project_slug(&local_root))
20 .unwrap_or_else(|| "project".to_string());
21
22 let fingerprint = RepositoryFingerprint {
23 remote_url,
24 host: parsed_remote.as_ref().map(|(host, _, _)| host.clone()),
25 owner: parsed_remote.as_ref().map(|(_, owner, _)| owner.clone()),
26 repo_name: parsed_remote.as_ref().map(|(_, _, repo_name)| repo_name.clone()),
27 default_branch,
28 };
29
30 let checkout_binding = CheckoutBinding {
31 project_id: "pending".to_string(),
32 local_root: Some(local_root.to_string_lossy().to_string()),
33 branch,
34 last_commit,
35 client_label: detect_client_label(),
36 last_sync: None,
37 };
38
39 ProjectContext {
40 project_slug,
41 project_root: local_root.to_string_lossy().to_string(),
42 fingerprint,
43 checkout_binding,
44 project_metadata: project_metadata::build_project_metadata(&local_root).ok(),
45 }
46}
47
48pub fn discover_repository_context(current_directory: &Path) -> ProjectContext {
49 discover_project_context(current_directory)
50}
51
52fn parse_remote_url(remote_url: &str) -> Option<(String, String, String)> {
53 let trimmed = remote_url.trim().trim_end_matches('/').trim_end_matches(".git");
54 if let Some(rest) = trimmed.strip_prefix("https://") {
55 return parse_host_path(rest);
56 }
57
58 if let Some(rest) = trimmed.strip_prefix("http://") {
59 return parse_host_path(rest);
60 }
61
62 if let Some(rest) = trimmed.strip_prefix("ssh://git@") {
63 return parse_host_path(rest);
64 }
65
66 if let Some(rest) = trimmed.strip_prefix("git@") {
67 let (host, path) = rest.split_once(':')?;
68 return parse_path_segments(host, path);
69 }
70
71 None
72}
73
74fn parse_host_path(value: &str) -> Option<(String, String, String)> {
75 let (host, path) = value.split_once('/')?;
76 parse_path_segments(host, path)
77}
78
79fn parse_path_segments(host: &str, path: &str) -> Option<(String, String, String)> {
80 let mut segments = path.split('/').filter(|segment| !segment.is_empty());
81 let owner = segments.next()?.to_string();
82 let repo_name = segments.next()?.to_string();
83 Some((host.to_string(), owner, repo_name))
84}
85
86fn git_output<const N: usize>(working_directory: &Path, args: [&str; N]) -> Option<String> {
87 let output = Command::new("git")
88 .args(args)
89 .current_dir(working_directory)
90 .output()
91 .ok()?;
92 if !output.status.success() {
93 return None;
94 }
95
96 let value = String::from_utf8(output.stdout).ok()?;
97 let trimmed = value.trim();
98 if trimmed.is_empty() {
99 return None;
100 }
101
102 Some(trimmed.to_string())
103}
104
105fn detect_client_label() -> Option<String> {
106 std::env::var("COMPUTERNAME")
107 .ok()
108 .filter(|value| !value.trim().is_empty())
109 .or_else(|| std::env::var("HOSTNAME").ok().filter(|value| !value.trim().is_empty()))
110}
111
112fn fallback_project_slug(local_root: &Path) -> Option<String> {
113 let raw = local_root.file_name()?.to_string_lossy().trim().to_string();
114 if raw.is_empty() {
115 return None;
116 }
117
118 let sanitized = raw
119 .chars()
120 .map(|ch| if ch.is_ascii_alphanumeric() { ch.to_ascii_lowercase() } else { '-' })
121 .collect::<String>()
122 .trim_matches('-')
123 .to_string();
124
125 if sanitized.is_empty() {
126 return None;
127 }
128
129 if sanitized.len() <= 4 {
130 return Some(format!("project-{sanitized}"));
131 }
132
133 Some(sanitized)
134}
135
136#[cfg(test)]
137mod tests {
138 use super::{fallback_project_slug, parse_remote_url};
139 use std::path::Path;
140
141 #[test]
142 fn parse_remote_url_supports_https_and_ssh_formats() {
143 assert_eq!(
144 parse_remote_url("https://github.com/MarkBovee/nebu-ctx.git"),
145 Some(("github.com".to_string(), "MarkBovee".to_string(), "nebu-ctx".to_string()))
146 );
147
148 assert_eq!(
149 parse_remote_url("git@github.com:MarkBovee/nebu-ctx.git"),
150 Some(("github.com".to_string(), "MarkBovee".to_string(), "nebu-ctx".to_string()))
151 );
152 }
153
154 #[test]
155 fn fallback_project_slug_avoids_tiny_ambiguous_names() {
156 assert_eq!(
157 fallback_project_slug(Path::new("/home/mark")),
158 Some("project-mark".to_string())
159 );
160 assert_eq!(
161 fallback_project_slug(Path::new("/repo/nebu-ctx")),
162 Some("nebu-ctx".to_string())
163 );
164 }
165}