use std::{
collections::BTreeSet,
fs,
io::{self, Write},
path::{Component, Path, PathBuf},
};
use crate::config::{DEFAULT_STATE_DIR, LEGACY_STATE_DIR, MemorySkillConfig};
use serde::{Deserialize, Serialize};
use super::{
render::{render_candidate_preview, render_skill},
scan::{scan_memory_skill_advisory, scan_memory_skill_candidate},
types::{
ADVISORIES_FILE, CANDIDATE_SCHEMA_VERSION, CandidateStatus, CandidateTransition,
MemorySkillAdvisory, MemorySkillCandidate, MemorySkillError, TRANSITIONS_FILE,
},
util::unix_now,
};
const TRANSITION_LOCK_STALE_AFTER_SECONDS: u64 = 300;
#[derive(Clone, Debug)]
pub struct MemorySkillStore {
candidate_dir: PathBuf,
approved_dir: PathBuf,
repo_root: PathBuf,
config: MemorySkillConfig,
}
#[derive(Debug)]
struct ApprovedSkillWrite {
path: PathBuf,
created: bool,
}
#[derive(Debug)]
struct TransitionLock {
path: PathBuf,
payload: String,
}
impl Drop for TransitionLock {
fn drop(&mut self) {
if matches!(fs::read_to_string(&self.path), Ok(contents) if contents == self.payload) {
let _ = fs::remove_file(&self.path);
}
}
}
impl MemorySkillStore {
pub fn new(state_dir: &Path, config: &MemorySkillConfig) -> Result<Self, MemorySkillError> {
Ok(Self {
candidate_dir: resolve_state_relative_path(state_dir, &config.candidate_dir)?,
approved_dir: resolve_approved_dir(state_dir, config),
repo_root: repo_root_for_state_dir(state_dir),
config: config.clone(),
})
}
pub fn candidate_dir(&self) -> &Path {
&self.candidate_dir
}
pub fn candidate_json_path(&self, id: &str) -> PathBuf {
self.candidate_dir.join(format!("{id}.json"))
}
pub fn candidate_markdown_path(&self, id: &str) -> PathBuf {
self.candidate_dir.join(format!("{id}.md"))
}
pub fn transitions_path(&self) -> PathBuf {
self.candidate_dir.join(TRANSITIONS_FILE)
}
pub fn advisories_path(&self) -> PathBuf {
self.candidate_dir.join(ADVISORIES_FILE)
}
fn transition_lock_path(&self) -> PathBuf {
self.candidate_dir.join("transitions.lock")
}
fn acquire_transition_lock(&self) -> Result<TransitionLock, MemorySkillError> {
fs::create_dir_all(&self.candidate_dir)?;
let path = self.transition_lock_path();
loop {
let file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path);
match file {
Ok(mut file) => {
let payload = format!(
"pid={}\ncreated_at_unix={}\n",
std::process::id(),
unix_now()
);
file.write_all(payload.as_bytes())?;
return Ok(TransitionLock { path, payload });
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
if remove_stale_transition_lock(&path)? {
continue;
}
return Err(MemorySkillError::TransitionLocked { path });
}
Err(error) => return Err(error.into()),
}
}
}
pub fn write_candidate(
&self,
candidate: &MemorySkillCandidate,
) -> Result<(), MemorySkillError> {
fs::create_dir_all(&self.candidate_dir)?;
scan_memory_skill_candidate(candidate, &self.config)?;
let json = serde_json::to_string_pretty(candidate)?;
let markdown = render_candidate_preview(candidate);
let json_path = self.candidate_json_path(&candidate.id);
let markdown_path = self.candidate_markdown_path(&candidate.id);
write_new_file(&json_path, json.as_bytes())?;
if let Err(error) = write_new_file(&markdown_path, markdown.as_bytes()) {
let _ = fs::remove_file(&json_path);
return Err(error);
}
Ok(())
}
pub fn write_candidate_if_fingerprint_absent(
&self,
candidate: &MemorySkillCandidate,
) -> Result<bool, MemorySkillError> {
let _lock = self.acquire_transition_lock()?;
if self.has_active_fingerprint(&candidate.fingerprint)? {
return Ok(false);
}
self.write_candidate(candidate)?;
Ok(true)
}
pub fn append_advisory(
&self,
advisory: &MemorySkillAdvisory,
) -> Result<bool, MemorySkillError> {
let _lock = self.acquire_transition_lock()?;
fs::create_dir_all(&self.candidate_dir)?;
if self.has_advisory_fingerprint(&advisory.fingerprint)? {
return Ok(false);
}
scan_memory_skill_advisory(advisory, &self.config)?;
append_jsonl(&self.advisories_path(), advisory)?;
Ok(true)
}
pub fn append_transition(
&self,
transition: &CandidateTransition,
) -> Result<(), MemorySkillError> {
let _lock = self.acquire_transition_lock()?;
self.append_transition_locked(transition)
}
fn append_transition_locked(
&self,
transition: &CandidateTransition,
) -> Result<(), MemorySkillError> {
fs::create_dir_all(&self.candidate_dir)?;
append_jsonl(&self.transitions_path(), transition)
}
pub fn read_candidates(&self) -> Result<Vec<MemorySkillCandidate>, MemorySkillError> {
let entries = match fs::read_dir(&self.candidate_dir) {
Ok(entries) => entries,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(error.into()),
};
let transitions = self.read_transitions()?;
let mut candidates = Vec::new();
for entry in entries {
let path = entry?.path();
if path.extension().is_none_or(|extension| extension != "json") {
continue;
}
let contents = match fs::read_to_string(&path) {
Ok(contents) => contents,
Err(error) => {
tracing::warn!(
path = %path.display(),
error = %error,
"skipping unreadable memory-skill candidate file"
);
continue;
}
};
let mut candidate: MemorySkillCandidate = match serde_json::from_str(&contents) {
Ok(candidate) => candidate,
Err(error) => {
tracing::warn!(
path = %path.display(),
error = %error,
"skipping invalid memory-skill candidate file"
);
continue;
}
};
candidate.status = effective_status(&candidate, &transitions);
candidates.push(candidate);
}
candidates.sort_by(|left, right| left.id.cmp(&right.id));
Ok(candidates)
}
pub fn read_advisories(&self) -> Result<Vec<MemorySkillAdvisory>, MemorySkillError> {
read_jsonl(&self.advisories_path())
}
pub fn dismiss_advisory(&self, id: &str) -> Result<(), MemorySkillError> {
let _lock = self.acquire_transition_lock()?;
let path = self.advisories_path();
let contents = match fs::read_to_string(&path) {
Ok(contents) => contents,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Err(MemorySkillError::AdvisoryNotFound { id: id.to_owned() });
}
Err(error) => return Err(error.into()),
};
let mut found = false;
let mut rewritten = String::new();
for line in contents.lines() {
if line.trim().is_empty() {
rewritten.push_str(line);
rewritten.push('\n');
continue;
}
match serde_json::from_str::<MemorySkillAdvisory>(line) {
Ok(mut advisory) if advisory.id == id => {
advisory.dismissed = true;
rewritten.push_str(&serde_json::to_string(&advisory)?);
rewritten.push('\n');
found = true;
}
_ => {
rewritten.push_str(line);
rewritten.push('\n');
}
}
}
if !contents.ends_with('\n') && rewritten.ends_with('\n') {
rewritten.pop();
}
if !found {
return Err(MemorySkillError::AdvisoryNotFound { id: id.to_owned() });
}
write_bytes_atomic(&path, rewritten.as_bytes())
}
pub fn has_advisory_fingerprint(&self, fingerprint: &str) -> Result<bool, MemorySkillError> {
Ok(self
.read_advisories()?
.into_iter()
.any(|advisory| advisory.fingerprint == fingerprint))
}
pub fn read_transitions(&self) -> Result<Vec<CandidateTransition>, MemorySkillError> {
read_jsonl(&self.transitions_path())
}
pub fn show(&self, id: &str) -> Result<MemorySkillCandidate, MemorySkillError> {
self.read_candidates()?
.into_iter()
.find(|candidate| candidate.id == id)
.ok_or_else(|| MemorySkillError::CandidateNotFound { id: id.to_owned() })
}
pub fn has_active_fingerprint(&self, fingerprint: &str) -> Result<bool, MemorySkillError> {
Ok(self.read_candidates()?.into_iter().any(|candidate| {
candidate.fingerprint == fingerprint
&& !matches!(
candidate.status,
CandidateStatus::Rejected | CandidateStatus::Superseded
)
}))
}
pub fn pending_candidates(&self) -> Result<Vec<MemorySkillCandidate>, MemorySkillError> {
Ok(self
.read_candidates()?
.into_iter()
.filter(|candidate| candidate.status == CandidateStatus::Pending)
.collect())
}
pub fn pending_for_commits(
&self,
commits: &BTreeSet<&str>,
) -> Result<Vec<MemorySkillCandidate>, MemorySkillError> {
Ok(self
.pending_candidates()?
.into_iter()
.filter(|candidate| commits.contains(candidate.source_commit.as_str()))
.collect())
}
pub fn approve(&self, id: &str, apply: bool) -> Result<Option<PathBuf>, MemorySkillError> {
let _lock = self.acquire_transition_lock()?;
let candidate = self.show(id)?;
let skill = self.render_validated_skill(&candidate)?;
let from = candidate.status;
let (to, action) = if apply {
validate_transition(from, CandidateStatus::Applied)?;
(CandidateStatus::Applied, "approve_apply")
} else {
validate_transition(from, CandidateStatus::Approved)?;
(CandidateStatus::Approved, "approve")
};
let rendered = if apply {
Some(self.write_approved_skill(&candidate, &skill)?)
} else {
None
};
let transition = CandidateTransition {
schema_version: CANDIDATE_SCHEMA_VERSION,
candidate_id: candidate.id,
fingerprint: candidate.fingerprint,
from,
to,
action: action.to_owned(),
actor: "human".to_owned(),
reason: "Approved by local CLI.".to_owned(),
rendered_path: rendered
.as_ref()
.map(|write| write.path.to_string_lossy().into_owned()),
created_at_unix: unix_now(),
};
if let Err(error) = self.append_transition_locked(&transition) {
if let Some(write) = &rendered {
rollback_approved_write(write);
}
return Err(error);
}
Ok(rendered.map(|write| write.path))
}
pub fn apply(&self, id: &str) -> Result<PathBuf, MemorySkillError> {
let _lock = self.acquire_transition_lock()?;
let candidate = self.show(id)?;
let from = candidate.status;
if from != CandidateStatus::Approved {
return Err(MemorySkillError::InvalidTransition {
from,
to: CandidateStatus::Applied,
});
}
validate_transition(from, CandidateStatus::Applied)?;
let skill = self.render_validated_skill(&candidate)?;
let rendered = self.write_approved_skill(&candidate, &skill)?;
let transition = CandidateTransition {
schema_version: CANDIDATE_SCHEMA_VERSION,
candidate_id: candidate.id,
fingerprint: candidate.fingerprint,
from,
to: CandidateStatus::Applied,
action: "apply".to_owned(),
actor: "human".to_owned(),
reason: "Applied by local CLI.".to_owned(),
rendered_path: Some(rendered.path.to_string_lossy().into_owned()),
created_at_unix: unix_now(),
};
if let Err(error) = self.append_transition_locked(&transition) {
rollback_approved_write(&rendered);
return Err(error);
}
Ok(rendered.path)
}
pub fn reject(&self, id: &str, reason: &str) -> Result<(), MemorySkillError> {
let reason = reason.trim();
if reason.is_empty() {
return Err(MemorySkillError::EmptyRejectReason);
}
let _lock = self.acquire_transition_lock()?;
let candidate = self.show(id)?;
let from = candidate.status;
validate_transition(from, CandidateStatus::Rejected)?;
self.append_transition_locked(&CandidateTransition {
schema_version: CANDIDATE_SCHEMA_VERSION,
candidate_id: candidate.id,
fingerprint: candidate.fingerprint,
from,
to: CandidateStatus::Rejected,
action: "reject".to_owned(),
actor: "human".to_owned(),
reason: reason.to_owned(),
rendered_path: None,
created_at_unix: unix_now(),
})
}
pub fn supersede(
&self,
id: &str,
replacement_id: &str,
reason: &str,
) -> Result<(), MemorySkillError> {
let id = id.trim();
let replacement_id = replacement_id.trim();
let reason = reason.trim();
if replacement_id.is_empty() || reason.is_empty() {
return Err(MemorySkillError::EmptySupersedeReason);
}
if id == replacement_id {
return Err(MemorySkillError::SelfSupersede { id: id.to_owned() });
}
let _lock = self.acquire_transition_lock()?;
let candidate = self.show(id)?;
let replacement = self.show(replacement_id)?;
if !valid_supersede_replacement_status(replacement.status) {
return Err(MemorySkillError::InvalidSupersedeReplacement {
id: replacement.id,
status: replacement.status,
});
}
let from = candidate.status;
validate_transition(from, CandidateStatus::Superseded)?;
self.append_transition_locked(&CandidateTransition {
schema_version: CANDIDATE_SCHEMA_VERSION,
candidate_id: candidate.id,
fingerprint: candidate.fingerprint,
from,
to: CandidateStatus::Superseded,
action: "supersede".to_owned(),
actor: "human".to_owned(),
reason: format!("{reason} replacement={}", replacement.id),
rendered_path: None,
created_at_unix: unix_now(),
})
}
fn render_validated_skill(
&self,
candidate: &MemorySkillCandidate,
) -> Result<String, MemorySkillError> {
let skill = render_skill(candidate);
if skill.len() > self.config.max_skill_bytes {
return Err(MemorySkillError::RenderedSkillTooLarge {
bytes: skill.len(),
max: self.config.max_skill_bytes,
});
}
scan_memory_skill_candidate(candidate, &self.config)?;
Ok(skill)
}
fn write_approved_skill(
&self,
candidate: &MemorySkillCandidate,
skill: &str,
) -> Result<ApprovedSkillWrite, MemorySkillError> {
let configured_approved_dir = Path::new(&self.config.approved_dir);
if !self.config.allow_global_writes && configured_approved_dir.is_absolute() {
return Err(MemorySkillError::UnsafeGlobalWrite {
path: configured_approved_dir.to_path_buf(),
});
}
if !self.config.allow_global_writes && contains_parent_dir(configured_approved_dir) {
return Err(MemorySkillError::UnsafeApprovedPath {
path: configured_approved_dir.to_path_buf(),
});
}
let slug = format!("{}{}", self.config.approved_slug_prefix, candidate.slug);
validate_single_path_segment(&slug)?;
if !self.config.allow_global_writes {
reject_symlink_component_under(&self.repo_root, &self.approved_dir)?;
}
fs::create_dir_all(&self.approved_dir)?;
let approved_root = if self.config.allow_global_writes {
None
} else {
let repo_root = fs::canonicalize(&self.repo_root)?;
let approved_root = fs::canonicalize(&self.approved_dir)?;
if !approved_root.starts_with(&repo_root) {
return Err(MemorySkillError::UnsafeApprovedPath {
path: self.approved_dir.clone(),
});
}
Some(approved_root)
};
let skill_dir = self.approved_dir.join(slug);
if !self.config.allow_global_writes {
reject_symlink_component_under(&self.repo_root, &skill_dir)?;
}
fs::create_dir_all(&skill_dir)?;
if let Some(approved_root) = &approved_root {
let skill_dir_root = fs::canonicalize(&skill_dir)?;
if !skill_dir_root.starts_with(approved_root) {
return Err(MemorySkillError::UnsafeApprovedPath { path: skill_dir });
}
}
let path = skill_dir.join("SKILL.md");
let file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path);
match file {
Ok(mut file) => {
file.write_all(skill.as_bytes())?;
Ok(ApprovedSkillWrite {
path,
created: true,
})
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
if !self.config.allow_global_writes
&& fs::symlink_metadata(&path)
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
{
return Err(MemorySkillError::UnsafeApprovedPath { path });
}
if fs::read_to_string(&path)? == skill {
Ok(ApprovedSkillWrite {
path,
created: false,
})
} else {
Err(error.into())
}
}
Err(error) => Err(error.into()),
}
}
}
fn valid_supersede_replacement_status(status: CandidateStatus) -> bool {
matches!(
status,
CandidateStatus::Pending | CandidateStatus::Approved | CandidateStatus::Applied
)
}
fn rollback_approved_write(write: &ApprovedSkillWrite) {
if write.created {
let _ = fs::remove_file(&write.path);
}
}
fn remove_stale_transition_lock(path: &Path) -> Result<bool, MemorySkillError> {
let stale_by_file_age = transition_lock_file_age_is_stale(path)?;
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(true),
Err(_) if stale_by_file_age => return remove_transition_lock(path),
Err(_) => return Ok(false),
};
let Some(created_at_unix) = parse_lock_created_at(&contents) else {
return if stale_by_file_age {
remove_transition_lock(path)
} else {
Ok(false)
};
};
if unix_now().saturating_sub(created_at_unix) <= TRANSITION_LOCK_STALE_AFTER_SECONDS {
return Ok(false);
}
remove_transition_lock(path)
}
fn transition_lock_file_age_is_stale(path: &Path) -> Result<bool, MemorySkillError> {
let metadata = match fs::metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(true),
Err(error) => return Err(error.into()),
};
let Ok(modified_at) = metadata.modified() else {
return Ok(false);
};
Ok(std::time::SystemTime::now()
.duration_since(modified_at)
.is_ok_and(|age| age.as_secs() > TRANSITION_LOCK_STALE_AFTER_SECONDS))
}
fn remove_transition_lock(path: &Path) -> Result<bool, MemorySkillError> {
match fs::remove_file(path) {
Ok(()) => Ok(true),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(true),
Err(error) => Err(error.into()),
}
}
fn parse_lock_created_at(contents: &str) -> Option<u64> {
contents.lines().find_map(|line| {
line.strip_prefix("created_at_unix=")
.and_then(|value| value.parse().ok())
})
}
fn effective_status(
candidate: &MemorySkillCandidate,
transitions: &[CandidateTransition],
) -> CandidateStatus {
transitions
.iter()
.filter(|transition| {
transition.candidate_id == candidate.id
&& transition.fingerprint == candidate.fingerprint
})
.fold(CandidateStatus::Pending, |status, transition| {
if transition.from == status && validate_transition(status, transition.to).is_ok() {
transition.to
} else {
status
}
})
}
fn validate_transition(from: CandidateStatus, to: CandidateStatus) -> Result<(), MemorySkillError> {
let valid = matches!(
(from, to),
(CandidateStatus::Pending, CandidateStatus::Approved)
| (CandidateStatus::Pending, CandidateStatus::Applied)
| (CandidateStatus::Approved, CandidateStatus::Applied)
| (CandidateStatus::Pending, CandidateStatus::Rejected)
| (CandidateStatus::Approved, CandidateStatus::Rejected)
| (CandidateStatus::Pending, CandidateStatus::Superseded)
| (CandidateStatus::Approved, CandidateStatus::Superseded)
);
if valid {
Ok(())
} else {
Err(MemorySkillError::InvalidTransition { from, to })
}
}
fn append_jsonl<T: Serialize>(path: &Path, value: &T) -> Result<(), MemorySkillError> {
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
let mut line = serde_json::to_vec(value)?;
line.push(b'\n');
file.write_all(&line)?;
Ok(())
}
fn write_bytes_atomic(path: &Path, bytes: &[u8]) -> Result<(), MemorySkillError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let temp_path = path.with_extension(format!(
"{}.tmp",
path.extension()
.and_then(|extension| extension.to_str())
.unwrap_or("jsonl")
));
{
let mut file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&temp_path)?;
file.write_all(bytes)?;
file.sync_all()?;
}
fs::rename(&temp_path, path)?;
Ok(())
}
fn write_new_file(path: &Path, contents: &[u8]) -> Result<(), MemorySkillError> {
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?;
file.write_all(contents)?;
Ok(())
}
fn read_jsonl<T>(path: &Path) -> Result<Vec<T>, MemorySkillError>
where
T: for<'de> Deserialize<'de>,
{
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(error.into()),
};
let mut values = Vec::new();
for (index, line) in contents.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str(line) {
Ok(value) => values.push(value),
Err(error) => {
tracing::warn!(
path = %path.display(),
line = index + 1,
error = %error,
"skipping invalid memory-skill JSONL record"
);
}
}
}
Ok(values)
}
fn resolve_state_relative_path(state_dir: &Path, raw: &str) -> Result<PathBuf, MemorySkillError> {
let path = PathBuf::from(raw);
if path.is_absolute() || path.starts_with(state_dir) {
return checked_state_path(state_dir, path, raw);
}
if let Ok(stripped) = path.strip_prefix(DEFAULT_STATE_DIR) {
return checked_state_path(state_dir, state_dir.join(stripped), raw);
}
if let Ok(stripped) = path.strip_prefix(LEGACY_STATE_DIR) {
return checked_state_path(state_dir, state_dir.join(stripped), raw);
}
checked_state_path(state_dir, state_dir.join(path), raw)
}
fn checked_state_path(
state_dir: &Path,
resolved: PathBuf,
raw: &str,
) -> Result<PathBuf, MemorySkillError> {
if contains_parent_dir(&resolved) || !resolved.starts_with(state_dir) {
return Err(MemorySkillError::UnsafeStatePath {
path: PathBuf::from(raw),
});
}
Ok(resolved)
}
pub(crate) fn resolve_approved_dir(state_dir: &Path, config: &MemorySkillConfig) -> PathBuf {
let path = PathBuf::from(&config.approved_dir);
if path.is_absolute() {
return path;
}
repo_root_for_state_dir(state_dir).join(path)
}
pub(crate) fn repo_root_for_state_dir(state_dir: &Path) -> PathBuf {
if let Some(root) = git_root_for_state_dir(state_dir) {
return root;
}
for ancestor in state_dir.ancestors() {
let Some(name) = ancestor.file_name().and_then(|name| name.to_str()) else {
continue;
};
if matches!(name, DEFAULT_STATE_DIR | crate::config::LEGACY_STATE_DIR)
&& let Some(parent) = ancestor
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
return parent.to_path_buf();
}
}
state_dir
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
.to_path_buf()
}
fn git_root_for_state_dir(state_dir: &Path) -> Option<PathBuf> {
state_dir
.ancestors()
.find(|ancestor| ancestor.join(".git").exists())
.map(normalize_repo_root)
}
fn normalize_repo_root(path: &Path) -> PathBuf {
if path.as_os_str().is_empty() {
PathBuf::from(".")
} else {
path.to_path_buf()
}
}
fn contains_parent_dir(path: &Path) -> bool {
path.components()
.any(|component| matches!(component, Component::ParentDir))
}
fn validate_single_path_segment(value: &str) -> Result<(), MemorySkillError> {
let path = Path::new(value);
let mut components = path.components();
if path.is_absolute()
|| !matches!(components.next(), Some(Component::Normal(_)))
|| components.next().is_some()
{
return Err(MemorySkillError::UnsafeApprovedPath {
path: PathBuf::from(value),
});
}
Ok(())
}
fn reject_symlink_component_under(root: &Path, path: &Path) -> Result<(), MemorySkillError> {
let relative = path.strip_prefix(root).unwrap_or(path);
let mut current = root.to_path_buf();
for component in relative.components() {
current.push(component.as_os_str());
let Ok(metadata) = fs::symlink_metadata(¤t) else {
continue;
};
if metadata.file_type().is_symlink() {
return Err(MemorySkillError::UnsafeApprovedPath { path: current });
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_discovered_repo_root_normalizes_to_current_directory() {
assert_eq!(normalize_repo_root(Path::new("")), PathBuf::from("."));
}
}