use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use crate::error::Result;
use crate::storage::{hex_lower, CaptureRow};
pub fn render(row: &CaptureRow) -> String {
use std::fmt::Write as _;
let mut out = String::new();
out.push_str("---\n");
let _ = writeln!(out, "timestamp: {}", row.ts.to_rfc3339());
let _ = writeln!(out, "kind: {}", row.kind.as_str());
let _ = writeln!(out, "sha256: {}", hex_lower(&row.sha256));
let _ = writeln!(out, "size_bytes: {}", row.size_bytes);
if let Some(conf) = row.ocr_confidence {
let _ = writeln!(out, "ocr_confidence: {conf}");
}
if let Some(app) = &row.source_app {
let _ = writeln!(out, "source_app: \"{app}\"");
}
if let Some(url) = &row.source_url {
let _ = writeln!(out, "source_url: \"{url}\"");
}
out.push_str("---\n");
match &row.content {
Some(body) => {
out.push_str(body);
if !body.ends_with('\n') {
out.push('\n');
}
}
None => out.push('\n'),
}
out
}
pub fn append(path: &Path, row: &CaptureRow) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
file.write_all(render(row).as_bytes())?;
Ok(())
}
pub fn daily_path(log_dir: &str, date_format: &str, ts: DateTime<Utc>) -> PathBuf {
let mut base = super::expand_tilde(log_dir);
let date = ts.format(date_format).to_string();
base.push(format!("{date}.md"));
base
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use tempfile::TempDir;
fn sample_text_row(content: &str) -> CaptureRow {
CaptureRow {
id: 0,
ts: Utc.with_ymd_and_hms(2026, 4, 17, 12, 34, 56).unwrap(),
kind: crate::storage::Kind::Text,
sha256: [0xab; 32],
size_bytes: content.len(),
content: Some(content.to_string()),
ocr_confidence: None,
source_app: None,
source_url: None,
md_path: PathBuf::from("/tmp/textlog/2026-04-17.md"),
}
}
fn sample_image_row(ocr: &str, confidence: f32) -> CaptureRow {
CaptureRow {
id: 0,
ts: Utc.with_ymd_and_hms(2026, 4, 17, 12, 34, 56).unwrap(),
kind: crate::storage::Kind::Image,
sha256: [0x9f; 32],
size_bytes: 82_431,
content: Some(ocr.to_string()),
ocr_confidence: Some(confidence),
source_app: Some("Safari".into()),
source_url: None,
md_path: PathBuf::from("/tmp/textlog/2026-04-17.md"),
}
}
#[test]
fn render_text_includes_required_frontmatter_fields() {
let row = sample_text_row("hello world");
let md = render(&row);
assert!(md.contains("timestamp: 2026-04-17T12:34:56+00:00"));
assert!(md.contains("kind: text"));
assert!(md.contains("size_bytes: 11"));
assert!(md.contains(&format!("sha256: {}", "ab".repeat(32))));
}
#[test]
fn render_text_body_is_content() {
let row = sample_text_row("error: no space left on device");
let md = render(&row);
assert!(
md.contains("\nerror: no space left on device\n"),
"body must appear after the second `---` delimiter; got:\n{md}"
);
}
#[test]
fn render_starts_and_ends_with_delimiters() {
let md = render(&sample_text_row("body"));
assert!(md.starts_with("---\n"), "render must start with `---`\n{md}");
assert!(md.ends_with('\n'), "render must end with a newline\n{md}");
assert_eq!(md.matches("\n---\n").count(), 1, "exactly one closing delim");
}
#[test]
fn render_image_includes_ocr_confidence() {
let md = render(&sample_image_row("captured text", 0.93));
assert!(md.contains("kind: image"));
assert!(md.contains("ocr_confidence: 0.93"));
assert!(md.contains("source_app: \"Safari\""));
}
#[test]
fn render_image_body_is_ocr_text() {
let md = render(&sample_image_row("OCR'd line", 0.5));
assert!(md.contains("\nOCR'd line\n"), "image body should be OCR text:\n{md}");
}
#[test]
fn render_omits_optional_fields_when_none() {
let md = render(&sample_text_row("plain"));
assert!(!md.contains("source_app:"), "no source_app line when None");
assert!(!md.contains("source_url:"), "no source_url line when None");
assert!(!md.contains("ocr_confidence:"), "no ocr_confidence line for text");
}
#[test]
fn render_includes_source_url_when_some() {
let mut row = sample_text_row("see this");
row.source_url = Some("https://example.com/x".into());
let md = render(&row);
assert!(md.contains("source_url: \"https://example.com/x\""));
}
#[test]
fn render_sha256_is_lowercase_hex_64_chars() {
let row = sample_text_row("x");
let md = render(&row);
let line = md
.lines()
.find(|l| l.starts_with("sha256: "))
.expect("sha256 line present");
let hex = line.trim_start_matches("sha256: ");
assert_eq!(hex.len(), 64, "sha256 hex must be 64 chars, got {}", hex.len());
assert!(hex.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
}
#[test]
fn daily_path_uses_date_format() {
let ts = Utc.with_ymd_and_hms(2026, 4, 17, 0, 0, 0).unwrap();
let p = daily_path("/var/log/textlog", "%Y-%m-%d", ts);
assert_eq!(p, PathBuf::from("/var/log/textlog/2026-04-17.md"));
}
#[test]
fn daily_path_supports_custom_date_format() {
let ts = Utc.with_ymd_and_hms(2026, 1, 5, 0, 0, 0).unwrap();
let p = daily_path("/logs", "%Y/%m/%d", ts);
assert_eq!(p, PathBuf::from("/logs/2026/01/05.md"));
}
#[test]
fn daily_path_expands_leading_tilde() {
let ts = Utc.with_ymd_and_hms(2026, 4, 17, 0, 0, 0).unwrap();
let p = daily_path("~/textlog/logs", "%Y-%m-%d", ts);
let s = p.to_string_lossy();
assert!(!s.starts_with("~/"), "tilde must be expanded, got `{s}`");
assert!(s.ends_with("/textlog/logs/2026-04-17.md"), "got `{s}`");
}
#[test]
fn append_creates_parent_directory_and_writes() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("nested/dir/2026-04-17.md");
let row = sample_text_row("first entry");
append(&path, &row).expect("first append must succeed");
let body = std::fs::read_to_string(&path).expect("file should exist");
assert!(body.contains("first entry"));
assert!(body.contains("kind: text"));
}
#[test]
fn append_appends_without_truncating() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("2026-04-17.md");
append(&path, &sample_text_row("alpha")).unwrap();
append(&path, &sample_text_row("bravo")).unwrap();
let body = std::fs::read_to_string(&path).unwrap();
assert!(body.contains("alpha"));
assert!(body.contains("bravo"));
let opening_delims =
usize::from(body.starts_with("---\n")) + body.matches("\n---\n").count();
assert!(
opening_delims >= 2,
"expected at least 2 frontmatter blocks, got {opening_delims}\n{body}"
);
}
}