use serde::{Deserialize, Serialize};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ProjectIdentity {
pub root_dir: PathBuf,
pub git_remote: Option<String>,
pub display_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectMarker {
pub name: Option<String>,
#[serde(default)]
pub aliases: Vec<String>,
}
impl ProjectIdentity {
pub fn resolve(cwd: &Path) -> Self {
let raw_root = Self::find_project_root(cwd);
let root_dir = raw_root.canonicalize().unwrap_or(raw_root);
let display_name = Self::derive_display_name(&root_dir);
let git_remote = Self::detect_git_remote(&root_dir);
Self {
root_dir,
git_remote,
display_name,
}
}
fn find_project_root(start: &Path) -> PathBuf {
let mut current = start.to_path_buf();
for _ in 0..256 {
if current.join(".nexus").join("project.toml").exists() {
return current;
}
if current.join(".git").exists() {
return current;
}
if !current.pop() {
break;
}
}
start.to_path_buf()
}
fn detect_git_remote(root: &Path) -> Option<String> {
let mut child = std::process::Command::new("git")
.args(["config", "--get", "remote.origin.url"])
.current_dir(root)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.ok()?;
let deadline = Instant::now() + Duration::from_secs(2);
loop {
match child.try_wait() {
Ok(Some(status)) => {
if !status.success() {
return None;
}
let mut buf = String::new();
child.stdout?.read_to_string(&mut buf).ok()?;
return Some(buf.trim().to_string());
}
Ok(None) => {
if Instant::now() > deadline {
let _ = child.kill();
return None;
}
std::thread::sleep(Duration::from_millis(50));
}
Err(_) => {
let _ = child.kill();
return None;
}
}
}
}
fn derive_display_name(root: &Path) -> String {
if let Ok(content) = std::fs::read_to_string(root.join(".nexus").join("project.toml")) {
if let Ok(marker) = toml::from_str::<ProjectMarker>(&content) {
if let Some(name) = marker.name {
return name;
}
}
}
root.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown-project")
.to_string()
}
pub fn cache_key(&self) -> String {
self.root_dir.to_string_lossy().to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_resolve_fallback() {
let dir = tempdir().unwrap();
let identity = ProjectIdentity::resolve(dir.path());
assert_eq!(identity.root_dir, dir.path());
assert!(identity.git_remote.is_none());
}
#[test]
fn test_resolve_with_marker() {
let dir = tempdir().unwrap();
let nexus_dir = dir.path().join(".nexus");
std::fs::create_dir(&nexus_dir).unwrap();
std::fs::write(nexus_dir.join("project.toml"), r#"name = "test-project""#).unwrap();
let sub_dir = dir.path().join("sub");
std::fs::create_dir(&sub_dir).unwrap();
let identity = ProjectIdentity::resolve(&sub_dir);
assert_eq!(identity.root_dir, dir.path());
assert_eq!(identity.display_name, "test-project");
}
#[test]
fn test_default_config_values() {
let dir = tempdir().unwrap();
let identity = ProjectIdentity::resolve(dir.path());
assert_eq!(
identity.display_name,
dir.path().file_name().unwrap().to_str().unwrap()
);
}
}