use anyhow::Result;
use std::path::{Path, PathBuf};
use crate::messaging::slugify_string;
pub const PERSONAL_PALACE: &str = "personal";
pub const PROJECT_MARKERS: &[&str] = &[
".git",
"Cargo.toml",
"pyproject.toml",
"package.json",
"go.mod",
".project-root",
];
pub fn find_project_root(start: &Path) -> Option<PathBuf> {
let mut current = start.to_path_buf();
if let Ok(canonical) = std::fs::canonicalize(¤t) {
current = canonical;
}
loop {
for marker in PROJECT_MARKERS {
if current.join(marker).exists() {
return Some(current);
}
}
match current.parent() {
Some(parent) if parent != current => current = parent.to_path_buf(),
_ => return None,
}
}
}
pub fn project_slug_at(start: &Path) -> Option<String> {
let root = find_project_root(start)?;
let basename = root.file_name()?.to_str()?;
let slug = slugify_string(basename);
if slug.is_empty() {
None
} else {
Some(slug)
}
}
pub fn project_slug() -> Result<Option<String>> {
let cwd = std::env::current_dir().map_err(|e| anyhow::anyhow!("read cwd: {e}"))?;
Ok(project_slug_at(&cwd))
}
pub fn validate_palace_name(name: &str, cwd: &Path) -> Result<()> {
if name == PERSONAL_PALACE {
return Ok(());
}
match project_slug_at(cwd) {
Some(expected) => {
if name == expected {
Ok(())
} else {
Err(anyhow::anyhow!(
"palace name '{name}' does not match the project slug '{expected}' \
(derived from {cwd}). \
Either use '{expected}' or use 'personal' for non-project memories.",
cwd = cwd.display(),
))
}
}
None => Err(anyhow::anyhow!(
"no project root found at or above '{cwd}'. \
Use 'personal' for memories not tied to a project, \
or run from inside a project directory that contains \
a .git file, Cargo.toml, pyproject.toml, or package.json.",
cwd = cwd.display(),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn project_slug_finds_git_root() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
fs::create_dir_all(root.join(".git")).unwrap();
let nested = root.join("crates").join("foo");
fs::create_dir_all(&nested).unwrap();
let found = find_project_root(&nested);
assert!(found.is_some(), "should find project root");
let found_canonical = fs::canonicalize(found.unwrap()).unwrap();
let root_canonical = fs::canonicalize(&root).unwrap();
assert_eq!(found_canonical, root_canonical);
}
#[test]
fn project_slug_returns_none_without_markers() {
let tmp = tempfile::tempdir().expect("tempdir");
let found = find_project_root(tmp.path());
assert!(
found.is_none(),
"bare tempdir should not resolve to a project root"
);
}
#[test]
fn project_slug_uses_first_ancestor_marker() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
fs::write(root.join("Cargo.toml"), "[package]").unwrap();
let sub = root.join("src");
fs::create_dir_all(&sub).unwrap();
let found = find_project_root(&sub);
assert!(found.is_some());
let found_canonical = fs::canonicalize(found.unwrap()).unwrap();
let root_canonical = fs::canonicalize(&root).unwrap();
assert_eq!(found_canonical, root_canonical);
}
#[test]
fn project_slug_at_returns_root_basename_slug() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("my-project");
fs::create_dir_all(root.join(".git")).unwrap();
let src = root.join("src");
fs::create_dir_all(&src).unwrap();
let slug = project_slug_at(&src).expect("should return slug");
assert_eq!(slug, "my-project");
}
#[test]
fn project_slug_at_normalises_case_and_underscores() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("My_Project");
fs::create_dir_all(root.join(".git")).unwrap();
let slug = project_slug_at(&root).expect("should return slug");
assert_eq!(slug, "my-project");
}
#[test]
fn project_slug_at_returns_none_without_markers() {
let tmp = tempfile::tempdir().expect("tempdir");
assert!(project_slug_at(tmp.path()).is_none());
}
#[test]
fn validate_palace_name_accepts_personal() {
let tmp = tempfile::tempdir().expect("tempdir");
let result = validate_palace_name(PERSONAL_PALACE, tmp.path());
assert!(
result.is_ok(),
"personal must always be accepted; got {result:?}"
);
}
#[test]
fn validate_palace_name_accepts_matching_slug() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("cool-app");
fs::create_dir_all(root.join(".git")).unwrap();
let sub = root.join("src");
fs::create_dir_all(&sub).unwrap();
let result = validate_palace_name("cool-app", &sub);
assert!(result.is_ok(), "matching slug must be accepted: {result:?}");
}
#[test]
fn validate_palace_name_rejects_mismatch() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("cool-app");
fs::create_dir_all(root.join(".git")).unwrap();
let sub = root.join("src");
fs::create_dir_all(&sub).unwrap();
let result = validate_palace_name("wrong-name", &sub);
assert!(result.is_err(), "mismatched name must be rejected");
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("cool-app"),
"error must mention the expected slug; got: {msg}"
);
}
#[test]
fn validate_palace_name_rejects_non_personal_without_project() {
let tmp = tempfile::tempdir().expect("tempdir");
let result = validate_palace_name("my-notes", tmp.path());
assert!(
result.is_err(),
"non-personal name outside a project must be rejected"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("personal"),
"error must mention 'personal'; got: {msg}"
);
}
}