use crate::config::Config;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
pub fn notes_path() -> PathBuf {
Config::minutes_dir().join("current-notes.md")
}
pub fn context_path() -> PathBuf {
Config::minutes_dir().join("current-context.txt")
}
pub fn recording_start_path() -> PathBuf {
Config::minutes_dir().join("recording-start.txt")
}
pub fn save_recording_start() -> std::io::Result<()> {
let path = recording_start_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
fs::write(&path, now.to_string())
}
fn elapsed_timestamp() -> Option<String> {
let path = recording_start_path();
if !path.exists() {
return None;
}
let start_str = fs::read_to_string(&path).ok()?;
let start_epoch: u64 = start_str.trim().parse().ok()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
let elapsed = now.saturating_sub(start_epoch);
let mins = elapsed / 60;
let secs = elapsed % 60;
Some(format!("{}:{:02}", mins, secs))
}
pub fn add_note(text: &str) -> Result<String, String> {
let pid_path = crate::pid::pid_path();
if !pid_path.exists() {
return Err("No recording in progress. Start one with: minutes record".into());
}
let timestamp = elapsed_timestamp().unwrap_or_else(|| "?:??".into());
let line = format!("[{}] {}", timestamp, text.trim());
let path = notes_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| format!("could not open notes file: {}", e))?;
writeln!(file, "{}", line).map_err(|e| format!("could not write note: {}", e))?;
tracing::info!(note = %line, "note added");
Ok(line)
}
pub fn save_context(text: &str) -> std::io::Result<()> {
let path = context_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, text.trim())
}
pub fn read_notes() -> Option<String> {
let path = notes_path();
if path.exists() {
fs::read_to_string(&path)
.ok()
.filter(|s| !s.trim().is_empty())
} else {
None
}
}
pub fn read_context() -> Option<String> {
let path = context_path();
if path.exists() {
fs::read_to_string(&path)
.ok()
.filter(|s| !s.trim().is_empty())
} else {
None
}
}
pub fn cleanup() {
let _ = fs::remove_file(notes_path());
let _ = fs::remove_file(context_path());
let _ = fs::remove_file(recording_start_path());
}
pub fn validate_meeting_path(meeting_path: &Path, meetings_root: &Path) -> Result<(), String> {
if meeting_path.extension().and_then(|ext| ext.to_str()) != Some("md") {
return Err("meeting path must point to a .md file".into());
}
let canonical_meeting = meeting_path.canonicalize().map_err(|e| {
format!(
"could not resolve meeting path {}: {}",
meeting_path.display(),
e
)
})?;
let canonical_root = meetings_root.canonicalize().map_err(|e| {
format!(
"could not resolve meetings directory {}: {}",
meetings_root.display(),
e
)
})?;
if !canonical_meeting.starts_with(&canonical_root) {
return Err(format!(
"meeting path must be inside {}",
canonical_root.display()
));
}
Ok(())
}
pub fn annotate_meeting(meeting_path: &Path, text: &str) -> Result<(), String> {
if !meeting_path.exists() {
return Err(format!(
"meeting file not found: {}",
meeting_path.display()
));
}
let now = chrono::Local::now()
.format("%b %d, post-meeting")
.to_string();
let note_line = format!("- [{}] {}", now, text.trim());
let mut content = fs::read_to_string(meeting_path).map_err(|e| e.to_string())?;
if let Some(pos) = content.find("\n## Notes") {
let pos = pos + 1; let notes_start = pos + "## Notes".len();
let next_section = content[notes_start..]
.find("\n## ")
.map(|i| notes_start + i);
let insert_pos = next_section.unwrap_or(content.len());
content.insert_str(insert_pos, &format!("\n{}\n", note_line));
} else {
let insert_before = ["## Transcript", "## Decisions", "## Action Items"];
let mut inserted = false;
for marker in &insert_before {
if let Some(pos) = content.find(marker) {
content.insert_str(pos, &format!("## Notes\n\n{}\n\n", note_line));
inserted = true;
break;
}
}
if !inserted {
content.push_str(&format!("\n## Notes\n\n{}\n", note_line));
}
}
fs::write(meeting_path, &content).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(meeting_path, fs::Permissions::from_mode(0o600));
}
tracing::info!(
meeting = %meeting_path.display(),
note = %text.trim(),
"post-meeting note added"
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn elapsed_timestamp_returns_none_without_recording() {
let _ = elapsed_timestamp();
}
#[test]
fn annotate_meeting_creates_notes_section() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test-meeting.md");
fs::write(
&path,
"---\ntitle: Test\n---\n\n## Summary\n\nGood meeting.\n\n## Transcript\n\n[0:00] Hello\n",
)
.unwrap();
annotate_meeting(&path, "Follow-up needed").unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("## Notes"));
assert!(content.contains("Follow-up needed"));
let notes_pos = content.find("## Notes").unwrap();
let transcript_pos = content.find("## Transcript").unwrap();
assert!(notes_pos < transcript_pos);
}
#[test]
fn annotate_meeting_appends_to_existing_notes() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test-meeting.md");
fs::write(
&path,
"---\ntitle: Test\n---\n\n## Notes\n\n- [4:23] First note\n\n## Transcript\n\n[0:00] Hello\n",
)
.unwrap();
annotate_meeting(&path, "Second note").unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("First note"));
assert!(content.contains("Second note"));
}
#[test]
fn annotate_meeting_rejects_nonexistent_file() {
let result = annotate_meeting(Path::new("/nonexistent/meeting.md"), "note");
assert!(result.is_err());
}
#[test]
fn validate_meeting_path_allows_files_inside_output_dir() {
let dir = TempDir::new().unwrap();
let meetings_dir = dir.path().join("meetings");
fs::create_dir_all(&meetings_dir).unwrap();
let meeting = meetings_dir.join("demo.md");
fs::write(&meeting, "# demo").unwrap();
let result = validate_meeting_path(&meeting, &meetings_dir);
assert!(result.is_ok());
}
#[test]
fn validate_meeting_path_rejects_files_outside_output_dir() {
let dir = TempDir::new().unwrap();
let meetings_dir = dir.path().join("meetings");
let outside_dir = dir.path().join("outside");
fs::create_dir_all(&meetings_dir).unwrap();
fs::create_dir_all(&outside_dir).unwrap();
let meeting = outside_dir.join("demo.md");
fs::write(&meeting, "# demo").unwrap();
let result = validate_meeting_path(&meeting, &meetings_dir);
assert!(result.is_err());
}
#[cfg(unix)]
#[test]
fn validate_meeting_path_rejects_symlink_escape() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let meetings_dir = dir.path().join("meetings");
let outside_dir = dir.path().join("outside");
fs::create_dir_all(&meetings_dir).unwrap();
fs::create_dir_all(&outside_dir).unwrap();
let target = outside_dir.join("secret.md");
fs::write(&target, "# secret").unwrap();
let link = meetings_dir.join("linked.md");
symlink(&target, &link).unwrap();
let result = validate_meeting_path(&link, &meetings_dir);
assert!(result.is_err());
}
}