Skip to main content

lean_ctx/
git_context.rs

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
166    #[test]
167    fn empty_fingerprint_is_not_safe_repository_identity() {
168        let fingerprint = crate::models::RepositoryFingerprint {
169            remote_url: None,
170            host: None,
171            owner: None,
172            repo_name: None,
173            default_branch: None,
174        };
175
176        assert!(!fingerprint.has_safe_identity());
177    }
178}