nexus_memory_core/
project_identity.rs1use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5use std::process::Stdio;
6use std::thread;
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct ProjectIdentity {
11 pub root_dir: PathBuf,
13
14 pub git_remote: Option<String>,
16
17 pub display_name: String,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ProjectMarker {
25 pub name: Option<String>,
26 #[serde(default)]
27 pub aliases: Vec<String>,
28}
29
30impl ProjectIdentity {
31 pub fn resolve(cwd: &Path) -> Self {
33 let raw_root = Self::find_project_root(cwd);
34 let root_dir = raw_root.canonicalize().unwrap_or(raw_root);
37 let display_name = Self::derive_display_name(&root_dir);
38 let git_remote = Self::detect_git_remote(&root_dir);
39
40 Self {
41 root_dir,
42 git_remote,
43 display_name,
44 }
45 }
46
47 fn find_project_root(start: &Path) -> PathBuf {
49 let mut current = start.to_path_buf();
50 for _ in 0..256 {
51 if current.join(".nexus").join("project.toml").exists() {
52 return current;
53 }
54 if current.join(".git").exists() {
55 return current;
56 }
57 if !current.pop() {
58 break;
59 }
60 }
61 start.to_path_buf()
63 }
64
65 fn detect_git_remote(root: &Path) -> Option<String> {
67 let root = root.to_path_buf();
69 let handle = thread::spawn(move || {
70 let output = std::process::Command::new("git")
71 .args(["config", "--get", "remote.origin.url"])
72 .current_dir(&root)
73 .stdout(Stdio::piped())
74 .stderr(Stdio::null())
75 .output()
76 .ok()?;
77
78 if output.status.success() {
79 let stdout = String::from_utf8_lossy(&output.stdout);
80 Some(stdout.trim().to_string())
81 } else {
82 None
83 }
84 });
85
86 handle.join().unwrap_or(None)
87 }
88
89 fn derive_display_name(root: &Path) -> String {
90 if let Ok(content) = std::fs::read_to_string(root.join(".nexus").join("project.toml")) {
92 if let Ok(marker) = toml::from_str::<ProjectMarker>(&content) {
93 if let Some(name) = marker.name {
94 return name;
95 }
96 }
97 }
98
99 root.file_name()
100 .and_then(|n| n.to_str())
101 .unwrap_or("unknown-project")
102 .to_string()
103 }
104
105 pub fn cache_key(&self) -> String {
107 self.root_dir.to_string_lossy().to_string()
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use tempfile::tempdir;
115
116 #[test]
117 fn test_resolve_fallback() {
118 let dir = tempdir().unwrap();
119 let identity = ProjectIdentity::resolve(dir.path());
120 assert_eq!(identity.root_dir, dir.path());
121 assert!(identity.git_remote.is_none());
122 }
123
124 #[test]
125 fn test_resolve_with_marker() {
126 let dir = tempdir().unwrap();
127 let nexus_dir = dir.path().join(".nexus");
128 std::fs::create_dir(&nexus_dir).unwrap();
129 std::fs::write(nexus_dir.join("project.toml"), r#"name = "test-project""#).unwrap();
130
131 let sub_dir = dir.path().join("sub");
132 std::fs::create_dir(&sub_dir).unwrap();
133
134 let identity = ProjectIdentity::resolve(&sub_dir);
135 assert_eq!(identity.root_dir, dir.path());
136 assert_eq!(identity.display_name, "test-project");
137 }
138
139 #[test]
140 fn test_default_config_values() {
141 let dir = tempdir().unwrap();
142 let identity = ProjectIdentity::resolve(dir.path());
143 assert_eq!(
144 identity.display_name,
145 dir.path().file_name().unwrap().to_str().unwrap()
146 );
147 }
148}