use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use std::path::{Component, Path, PathBuf};
const LOCAL_SAVE_ENABLE_ENV: &str = "REMEM_SAVE_MEMORY_LOCAL_COPY";
const LOCAL_SAVE_DIR_ENV: &str = "REMEM_SAVE_MEMORY_LOCAL_DIR";
pub(super) fn local_copy_enabled_override(requested: Option<bool>) -> bool {
requested.unwrap_or_else(|| env_enabled(LOCAL_SAVE_ENABLE_ENV, true))
}
pub fn sanitize_segment(raw: &str, fallback: &str, limit: usize) -> String {
let mut out = String::with_capacity(raw.len().min(limit));
let mut last_underscore = false;
for ch in raw.chars() {
let mapped = if ch.is_ascii_alphanumeric() || ch == '_' {
ch.to_ascii_lowercase()
} else {
'_'
};
if mapped == '_' {
if last_underscore {
continue;
}
last_underscore = true;
} else {
last_underscore = false;
}
out.push(mapped);
if out.len() >= limit {
break;
}
}
let trimmed = out.trim_matches('_');
if trimmed.is_empty() {
fallback.to_string()
} else {
trimmed.to_string()
}
}
pub fn resolve_local_note_path(
project: &str,
title: Option<&str>,
local_path: Option<&str>,
) -> Result<PathBuf> {
if let Some(raw) = local_path.and_then(non_empty_trimmed) {
let path = PathBuf::from(raw);
let abs = if path.is_absolute() {
path
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
};
confine_to_base(&abs)
} else {
let default_path = default_local_note_path(project, title)?;
let abs = if default_path.is_absolute() {
default_path
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(default_path)
};
confine_to_base(&abs)
}
}
fn normalize_path(path: &Path) -> PathBuf {
let mut out: Vec<Component> = Vec::new();
for component in path.components() {
match component {
Component::ParentDir => {
if matches!(out.last(), Some(Component::Normal(_))) {
out.pop();
}
}
Component::CurDir => {}
_ => out.push(component),
}
}
out.iter().collect()
}
fn confine_to_base(abs: &Path) -> Result<PathBuf> {
let raw_base = normalize_path(&{
let base = crate::db::try_data_dir()?;
if base.is_absolute() {
base
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(base)
}
});
let base = if raw_base.exists() {
raw_base.canonicalize().unwrap_or_else(|_| raw_base.clone())
} else {
raw_base.clone()
};
let normalized = normalize_path(abs);
let resolved = {
let mut remaining: Vec<std::ffi::OsString> = Vec::new();
let mut ancestor = normalized.clone();
loop {
if ancestor.exists() {
match ancestor.canonicalize() {
Ok(canon) => {
let mut result = canon;
for component in remaining.iter().rev() {
result = result.join(component);
}
break result;
}
Err(_) => break normalized.clone(),
}
}
if ancestor == raw_base || !ancestor.starts_with(&raw_base) {
break normalized.clone();
}
match ancestor.file_name() {
Some(name) => {
remaining.push(name.to_owned());
ancestor = match ancestor.parent() {
Some(p) => p.to_path_buf(),
None => break normalized.clone(),
};
}
None => break normalized.clone(),
}
}
};
let check_path = if resolved.is_symlink() {
match resolved.canonicalize() {
Ok(canon) => canon,
Err(_) => {
return Err(anyhow!("local_path is outside the allowed directory"));
}
}
} else {
resolved.clone()
};
if !check_path.starts_with(&base) || check_path == base {
return Err(anyhow!("local_path is outside the allowed directory"));
}
Ok(resolved)
}
pub(super) fn build_local_note_content(project: &str, title: &str, text: &str) -> String {
let now = Utc::now().to_rfc3339();
format!(
"---\nsource: remem.save_memory\nsaved_at: {}\nproject: {}\n---\n\n# {}\n\n{}\n",
now, project, title, text
)
}
pub(super) fn write_local_note(path: &Path, content: &str) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create local copy directory {}", parent.display()))?;
}
std::fs::write(path, content)
.with_context(|| format!("write local copy at {}", path.display()))?;
Ok(())
}
fn env_enabled(key: &str, default: bool) -> bool {
match std::env::var(key) {
Ok(value) => {
let lower = value.trim().to_ascii_lowercase();
!matches!(lower.as_str(), "0" | "false" | "no" | "off")
}
Err(_) => default,
}
}
fn default_local_note_path(project: &str, title: Option<&str>) -> Result<PathBuf> {
let base = std::env::var(LOCAL_SAVE_DIR_ENV)
.map(PathBuf::from)
.map(Ok)
.unwrap_or_else(|_| crate::db::try_data_dir().map(|dir| dir.join("manual-notes")))?;
let project_dir = sanitize_segment(project, "manual", 64);
let title_slug = sanitize_segment(title.unwrap_or("memory"), "memory", 64);
let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
Ok(base
.join(project_dir)
.join(format!("{}-{}.md", timestamp, title_slug)))
}
fn non_empty_trimmed(raw: &str) -> Option<&str> {
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}