Skip to main content

nexus_memory_core/
project_identity.rs

1//! Project identity resolution logic
2
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5use std::process::Stdio;
6use std::thread;
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        // Spawn a blocking thread to run the git command with a timeout
68        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        // Try reading .nexus/project.toml first
91        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    /// Stable hash key for database lookups and cache keying.
106    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}