use std::fs;
use std::path::{Component, Path, PathBuf};
use anyhow::{Context, Result, bail};
use crate::config::Config;
use crate::memo::Memo;
fn format_entry(memo: &Memo, config: &Config) -> String {
memo.created_at
.format(&config.obsidian.entry_format)
.to_string()
.replace("{body}", &memo.body)
}
fn insert_under_heading(content: &str, heading: &str, entry: &str) -> String {
let lines: Vec<&str> = content.lines().collect();
let heading_trimmed = heading.trim();
let heading_level = heading_trimmed.chars().take_while(|&c| c == '#').count();
let heading_idx = lines.iter().position(|line| line.trim() == heading_trimmed);
match heading_idx {
Some(idx) => {
let insert_before = lines[idx + 1..]
.iter()
.position(|line| {
let trimmed = line.trim();
let level = trimmed.chars().take_while(|&c| c == '#').count();
level > 0
&& level <= heading_level
&& trimmed.len() > level
&& trimmed.as_bytes()[level] == b' '
})
.map(|pos| idx + 1 + pos);
let insert_at = insert_before.unwrap_or(lines.len());
let mut output = lines[..insert_at].join("\n");
output.push('\n');
output.push_str(entry);
output.push('\n');
if insert_at < lines.len() {
output.push_str(&lines[insert_at..].join("\n"));
if content.ends_with('\n') {
output.push('\n');
}
}
output
}
None => {
let mut output = content.to_string();
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
output.push('\n');
output.push_str(heading);
output.push('\n');
output.push_str(entry);
output.push('\n');
output
}
}
}
fn resolve_template_path(memo: &Memo, config: &Config) -> Result<PathBuf> {
let relative_path = memo
.created_at
.format(&config.obsidian.template_path)
.to_string();
let relative_path = Path::new(&relative_path);
if relative_path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
}) {
bail!(
"Obsidian template_path must stay inside the vault: {}",
config.obsidian.template_path
);
}
Ok(config.obsidian.vault_path.join(relative_path))
}
pub fn append_memo(memo: &Memo, config: &Config) -> Result<()> {
let file_path = resolve_template_path(memo, config)?;
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
let entry = format_entry(memo, config);
let content = if file_path.exists() {
fs::read_to_string(&file_path)
.with_context(|| format!("Failed to read: {}", file_path.display()))?
} else {
String::new()
};
let new_content = insert_under_heading(&content, &config.obsidian.target_heading, &entry);
fs::write(&file_path, &new_content)
.with_context(|| format!("Failed to write: {}", file_path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{DateTime, Local, TimeZone};
fn fixed_time() -> DateTime<Local> {
Local.with_ymd_and_hms(2026, 3, 20, 14, 5, 32).unwrap()
}
#[test]
fn insert_under_existing_heading_places_entry_just_before_next_sibling() {
let content = "# Title\n\n## Memos\n- old entry\n\n## Other\nstuff\n";
let result = insert_under_heading(content, "## Memos", "- new entry");
assert_eq!(
result,
"# Title\n\n## Memos\n- old entry\n\n- new entry\n## Other\nstuff\n"
);
}
#[test]
fn insert_under_heading_skips_deeper_subheadings() {
let content = "## Memos\n- old\n\n### Sub\nstuff\n\n## Other\nfinal\n";
let result = insert_under_heading(content, "## Memos", "- new");
assert_eq!(
result,
"## Memos\n- old\n\n### Sub\nstuff\n\n- new\n## Other\nfinal\n"
);
}
#[test]
fn insert_creates_heading_when_missing() {
let content = "# Title\n\nSome content\n";
let result = insert_under_heading(content, "## Memos", "- first entry");
assert_eq!(
result,
"# Title\n\nSome content\n\n## Memos\n- first entry\n"
);
}
#[test]
fn insert_under_heading_appends_sequentially() {
let content = "# 2026-03-20\n\n## Memos\n";
let after_first = insert_under_heading(content, "## Memos", "- first");
let after_second = insert_under_heading(&after_first, "## Memos", "- second");
assert_eq!(
after_second,
"# 2026-03-20\n\n## Memos\n- first\n- second\n"
);
}
#[test]
fn format_entry_substitutes_timestamp_and_body() {
let config = Config::default();
let memo = Memo::at("test memo".into(), fixed_time());
assert_eq!(
format_entry(&memo, &config),
"- 2026-03-20 14:05: test memo"
);
}
#[test]
fn format_entry_preserves_percent_in_body() {
let mut config = Config::default();
config.obsidian.entry_format = "- %Y-%m-%d: {body}".into();
let memo = Memo::at("100% done %Y".into(), fixed_time());
assert_eq!(format_entry(&memo, &config), "- 2026-03-20: 100% done %Y");
}
#[test]
fn append_memo_writes_resolved_path_with_heading_and_entry() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.obsidian.vault_path = dir.path().to_path_buf();
config.obsidian.template_path = "daily/%Y/%Y-%m.md".into();
let memo = Memo::at("obsidian test".into(), fixed_time());
append_memo(&memo, &config).unwrap();
let file_path = dir.path().join("daily/2026/2026-03.md");
let content = fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "\n## Memos\n- 2026-03-20 14:05: obsidian test\n");
}
#[test]
fn template_path_rejects_escape_from_vault() {
let dir = tempfile::tempdir().unwrap();
let vault_path = dir.path().join("vault");
let mut config = Config::default();
config.obsidian.vault_path = vault_path.clone();
let memo = Memo::at("obsidian test".into(), fixed_time());
config.obsidian.template_path = "/tmp/funpou-outside.md".into();
assert!(
resolve_template_path(&memo, &config)
.unwrap_err()
.to_string()
.contains("must stay inside the vault")
);
config.obsidian.template_path = "../outside.md".into();
let err = append_memo(&memo, &config).unwrap_err();
assert!(err.to_string().contains("must stay inside the vault"));
assert!(!dir.path().join("outside.md").exists());
}
}