use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::messaging::slugify_string;
pub const PIN_SCHEMA_VERSION: u32 = 1;
pub const PIN_FILE_REL: &str = ".trusty-tools/trusty-memory.yaml";
pub const TRUSTY_TOOLS_DIR: &str = ".trusty-tools";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProjectPin {
pub schema_version: u32,
pub palace: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
pub const PERSONAL_PALACE: &str = "personal";
pub const PROJECT_MARKERS: &[&str] = &[
".git",
"Cargo.toml",
"pyproject.toml",
"package.json",
"go.mod",
".project-root",
TRUSTY_TOOLS_DIR,
];
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 read_project_pin(root: &Path) -> Result<Option<ProjectPin>> {
let pin_path = root.join(PIN_FILE_REL);
match std::fs::read_to_string(&pin_path) {
Ok(s) => {
let pin: ProjectPin = serde_yaml::from_str(&s)
.map_err(|e| anyhow::anyhow!("parse {}: {e}", pin_path.display()))?;
Ok(Some(pin))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(anyhow::anyhow!("read {}: {e}", pin_path.display())),
}
}
pub fn write_project_pin(root: &Path, pin: &ProjectPin) -> Result<PathBuf> {
let dir = root.join(TRUSTY_TOOLS_DIR);
std::fs::create_dir_all(&dir).map_err(|e| anyhow::anyhow!("create {}: {e}", dir.display()))?;
let pin_path = root.join(PIN_FILE_REL);
let tmp_path = pin_path.with_extension("yaml.tmp");
let yaml = serde_yaml::to_string(pin).map_err(|e| anyhow::anyhow!("serialise pin: {e}"))?;
let header = "# .trusty-tools/trusty-memory.yaml\n\
# This file pins the trusty-memory palace slug for this project.\n\
# Commit it so the linkage survives directory renames and drive reorgs.\n\
# Schema: https://github.com/bobmatnyc/trusty-tools (trusty-tools convention)\n\n";
let content = format!("{header}{yaml}");
std::fs::write(&tmp_path, &content)
.map_err(|e| anyhow::anyhow!("write {}: {e}", tmp_path.display()))?;
std::fs::rename(&tmp_path, &pin_path).map_err(|e| {
anyhow::anyhow!(
"rename {} → {}: {e}",
tmp_path.display(),
pin_path.display()
)
})?;
Ok(pin_path)
}
pub fn project_slug_from_basename(root: &Path) -> Option<String> {
let basename = root.file_name()?.to_str()?;
let slug = slugify_string(basename);
if slug.is_empty() {
None
} else {
Some(slug)
}
}
pub fn project_slug_at(start: &Path) -> Option<String> {
let root = find_project_root(start)?;
match read_project_pin(&root) {
Ok(Some(pin)) => return Some(pin.palace),
Ok(None) => {} Err(e) => {
tracing::warn!(
path = %root.join(PIN_FILE_REL).display(),
"could not read palace pin file ({e:#}); falling back to basename slug"
);
}
}
let slug = project_slug_from_basename(&root)?;
let pin = ProjectPin {
schema_version: PIN_SCHEMA_VERSION,
palace: slug.clone(),
note: None,
};
match write_project_pin(&root, &pin) {
Ok(path) => {
tracing::debug!(
slug = %slug,
path = %path.display(),
"wrote palace pin file (lazy init)"
);
}
Err(e) => {
tracing::warn!(
slug = %slug,
root = %root.display(),
"could not write palace pin file ({e:#}); slug will remain basename-derived"
);
}
}
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}"
);
}
#[test]
fn write_and_read_pin_round_trips() {
let tmp = tempfile::tempdir().expect("tempdir");
let pin = ProjectPin {
schema_version: PIN_SCHEMA_VERSION,
palace: "my-project".to_string(),
note: None,
};
write_project_pin(tmp.path(), &pin).expect("write ok");
let read_back = read_project_pin(tmp.path())
.expect("read ok")
.expect("Some(pin)");
assert_eq!(read_back, pin);
}
#[test]
fn write_pin_omits_null_note() {
let tmp = tempfile::tempdir().expect("tempdir");
let pin = ProjectPin {
schema_version: PIN_SCHEMA_VERSION,
palace: "alpha".to_string(),
note: None,
};
let path = write_project_pin(tmp.path(), &pin).expect("write ok");
let raw = std::fs::read_to_string(&path).expect("read raw ok");
assert!(
!raw.contains("null"),
"null note must be omitted; got:\n{raw}"
);
assert!(raw.contains("palace: alpha"), "slug must be present");
assert!(
raw.contains("schema_version: 1"),
"schema_version must be present"
);
}
#[test]
fn read_project_pin_returns_none_when_absent() {
let tmp = tempfile::tempdir().expect("tempdir");
let result = read_project_pin(tmp.path()).expect("no error");
assert!(result.is_none(), "absent pin must yield None");
}
#[test]
fn pin_file_read_when_present() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("actual-dir");
fs::create_dir_all(root.join(".git")).unwrap();
let pin = ProjectPin {
schema_version: PIN_SCHEMA_VERSION,
palace: "pinned-slug".to_string(),
note: None,
};
write_project_pin(&root, &pin).expect("write pin");
let sub = root.join("src");
fs::create_dir_all(&sub).unwrap();
let slug = project_slug_at(&sub).expect("slug");
assert_eq!(
slug, "pinned-slug",
"pin file must override the directory basename"
);
}
#[test]
fn absent_pin_writes_computed_slug() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("my-cool-project");
fs::create_dir_all(root.join(".git")).unwrap();
assert!(
read_project_pin(&root).expect("no err").is_none(),
"no pin before first call"
);
let slug = project_slug_at(&root).expect("slug");
assert_eq!(slug, "my-cool-project");
let pin = read_project_pin(&root)
.expect("no err")
.expect("pin written");
assert_eq!(pin.palace, "my-cool-project");
assert_eq!(pin.schema_version, PIN_SCHEMA_VERSION);
}
#[test]
fn renamed_dir_with_pin_resolves_to_original_slug() {
let tmp = tempfile::tempdir().expect("tempdir");
let old_root = tmp.path().join("old-name");
fs::create_dir_all(old_root.join(".git")).unwrap();
let pin = ProjectPin {
schema_version: PIN_SCHEMA_VERSION,
palace: "original-slug".to_string(),
note: None,
};
write_project_pin(&old_root, &pin).expect("write pin");
let new_root = tmp.path().join("new-name");
fs::rename(&old_root, &new_root).expect("rename");
let sub = new_root.join("src");
fs::create_dir_all(&sub).unwrap();
let slug = project_slug_at(&sub).expect("slug after rename");
assert_eq!(
slug, "original-slug",
"pin file must survive the directory rename"
);
}
#[test]
fn trusty_tools_dir_is_project_marker() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::create_dir_all(tmp.path().join(TRUSTY_TOOLS_DIR)).unwrap();
let found = find_project_root(tmp.path());
assert!(
found.is_some(),
".trusty-tools must trigger project-root detection"
);
}
#[cfg(unix)]
#[test]
fn lazy_write_non_fatal_on_readonly_dir() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("ro-project");
fs::create_dir_all(root.join(".git")).unwrap();
let mut perms = fs::metadata(&root).unwrap().permissions();
perms.set_mode(0o555);
fs::set_permissions(&root, perms).unwrap();
let slug = project_slug_at(&root);
let mut restore = fs::metadata(&root).unwrap().permissions();
restore.set_mode(0o755);
fs::set_permissions(&root, restore).unwrap();
assert!(
slug.is_some(),
"slug must be returned even when the pin write fails"
);
assert_eq!(slug.unwrap(), "ro-project");
}
}