Skip to main content

nexus_memory_core/
project_identity.rs

1//! Project identity resolution logic
2
3use serde::{Deserialize, Serialize};
4use std::io::Read;
5use std::path::{Path, PathBuf};
6use std::time::{Duration, Instant};
7
8/// Unique identity for a project, used as the cache key for per-project memories.
9#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct ProjectIdentity {
11    /// Canonical absolute path to the project root directory.
12    pub root_dir: PathBuf,
13
14    /// Git remote origin URL, if available.
15    pub git_remote: Option<String>,
16
17    /// Human-readable project name.
18    pub display_name: String,
19}
20
21/// Marker file for explicit project identity override.
22/// Lives at `.nexus/project.toml` in the project root.
23#[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    /// Resolve the project identity for the given working directory.
32    pub fn resolve(cwd: &Path) -> Self {
33        let raw_root = Self::find_project_root(cwd);
34        // Canonicalize to eliminate symlinks, redundant separators, trailing slashes.
35        // Fallback to raw path if canonicalization fails (shouldn't happen for real dirs).
36        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    /// Walk up directory tree looking for `.nexus/project.toml` or `.git/`.
48    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        // Reached filesystem root or iteration cap, use original start
62        start.to_path_buf()
63    }
64
65    /// Extract git remote origin URL. Never fails — returns None on error.
66    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        // Try reading .nexus/project.toml first
103        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    /// Stable hash key for database lookups and cache keying.
118    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}