nexus_memory_core/
project_identity.rs1use serde::{Deserialize, Serialize};
4use std::io::Read;
5use std::path::{Path, PathBuf};
6use std::time::{Duration, Instant};
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 mut child = std::process::Command::new("git")
68 .args(["config", "--get", "remote.origin.url"])
69 .current_dir(root)
70 .stdout(std::process::Stdio::piped())
71 .stderr(std::process::Stdio::null())
72 .spawn()
73 .ok()?;
74
75 let deadline = Instant::now() + Duration::from_secs(2);
76 loop {
77 match child.try_wait() {
78 Ok(Some(status)) => {
79 if !status.success() {
80 return None;
81 }
82 let mut buf = String::new();
83 child.stdout?.read_to_string(&mut buf).ok()?;
84 return Some(buf.trim().to_string());
85 }
86 Ok(None) => {
87 if Instant::now() > deadline {
88 let _ = child.kill();
89 return None;
90 }
91 std::thread::sleep(Duration::from_millis(50));
92 }
93 Err(_) => {
94 let _ = child.kill();
95 return None;
96 }
97 }
98 }
99 }
100
101 fn derive_display_name(root: &Path) -> String {
102 if let Ok(content) = std::fs::read_to_string(root.join(".nexus").join("project.toml")) {
104 if let Ok(marker) = toml::from_str::<ProjectMarker>(&content) {
105 if let Some(name) = marker.name {
106 return name;
107 }
108 }
109 }
110
111 root.file_name()
112 .and_then(|n| n.to_str())
113 .unwrap_or("unknown-project")
114 .to_string()
115 }
116
117 pub fn cache_key(&self) -> String {
119 self.root_dir.to_string_lossy().to_string()
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use tempfile::tempdir;
127
128 #[test]
129 fn test_resolve_fallback() {
130 let dir = tempdir().unwrap();
131 let identity = ProjectIdentity::resolve(dir.path());
132 assert_eq!(identity.root_dir, dir.path());
133 assert!(identity.git_remote.is_none());
134 }
135
136 #[test]
137 fn test_resolve_with_marker() {
138 let dir = tempdir().unwrap();
139 let nexus_dir = dir.path().join(".nexus");
140 std::fs::create_dir(&nexus_dir).unwrap();
141 std::fs::write(nexus_dir.join("project.toml"), r#"name = "test-project""#).unwrap();
142
143 let sub_dir = dir.path().join("sub");
144 std::fs::create_dir(&sub_dir).unwrap();
145
146 let identity = ProjectIdentity::resolve(&sub_dir);
147 assert_eq!(identity.root_dir, dir.path());
148 assert_eq!(identity.display_name, "test-project");
149 }
150
151 #[test]
152 fn test_default_config_values() {
153 let dir = tempdir().unwrap();
154 let identity = ProjectIdentity::resolve(dir.path());
155 assert_eq!(
156 identity.display_name,
157 dir.path().file_name().unwrap().to_str().unwrap()
158 );
159 }
160}