use std::path::{Path, PathBuf};
use chrono::{Local, NaiveDateTime};
use crate::{
AgdError, ErrorCode, IndexedDocument, Item, MUTATION_SCHEMA, Mutation, RootSet, Settings,
};
use super::patch::{StoredPatch, unified_diff};
#[derive(Clone, Debug)]
pub struct WriteContext {
pub actor: String,
pub project: String,
pub now: Option<NaiveDateTime>,
}
impl WriteContext {
pub fn org_now(&self) -> NaiveDateTime {
self.now.unwrap_or_else(|| Local::now().naive_local())
}
pub fn is_agent(&self) -> bool {
agent_name(&self.actor).is_some()
}
}
const AGENT_ACTOR_PREFIX: &str = "mcp:";
pub fn agent_name(actor: &str) -> Option<&str> {
actor.strip_prefix(AGENT_ACTOR_PREFIX)
}
pub fn agent_actor(client_name: &str) -> String {
let name = client_name
.strip_suffix("-mcp-client")
.filter(|name| !name.is_empty())
.unwrap_or(client_name);
format!("{AGENT_ACTOR_PREFIX}{name}")
}
#[derive(Debug)]
pub struct WriteEngine {
pub(super) settings: Settings,
pub(super) roots: RootSet,
pub(super) journal_path: PathBuf,
}
pub(super) const DRY_RUN_JOURNAL_ID: &str = "dry-run";
pub(super) struct PreparedFile {
pub(super) path: PathBuf,
pre_image: String,
pub(super) post_image: String,
patches: Vec<StoredPatch>,
pub(super) diff: String,
}
impl PreparedFile {
pub(super) fn new(
path: PathBuf,
pre_image: String,
post_image: String,
patches: Vec<StoredPatch>,
) -> Self {
let diff = unified_diff(&path, &pre_image, &post_image);
Self {
path,
pre_image,
post_image,
patches,
diff,
}
}
pub(super) fn journal_image<'a>(&'a self, item_ref: Option<&'a str>) -> JournalImage<'a> {
JournalImage {
path: &self.path,
item_ref,
pre_image: &self.pre_image,
post_image: &self.post_image,
patches: &self.patches,
diff: &self.diff,
}
}
}
pub(super) struct JournalImage<'a> {
pub(super) path: &'a Path,
pub(super) item_ref: Option<&'a str>,
pub(super) pre_image: &'a str,
pub(super) post_image: &'a str,
pub(super) patches: &'a [StoredPatch],
pub(super) diff: &'a str,
}
impl WriteEngine {
pub fn new(settings: Settings, journal_path: PathBuf) -> Result<Self, AgdError> {
let roots = RootSet::new(&settings.roots, &settings.agenda_files)?;
Ok(Self {
settings,
roots,
journal_path,
})
}
pub fn with_default_journal(settings: Settings) -> Result<Self, AgdError> {
Self::new(settings, crate::path::data_dir()?.join("journal.jsonl"))
}
}
pub(super) fn parse_text(
path: &Path,
text: &str,
settings: &Settings,
) -> Result<IndexedDocument, AgdError> {
IndexedDocument::parse(path.to_path_buf(), text.as_bytes().to_vec(), settings)
}
pub(super) fn parse_updated_item(
path: &Path,
source: &str,
line_hint: usize,
settings: &Settings,
) -> Result<Item, AgdError> {
let document =
IndexedDocument::parse(path.to_path_buf(), source.as_bytes().to_vec(), settings)?;
document
.headline_at_line(line_hint)
.or_else(|| {
document
.headlines()
.iter()
.min_by_key(|headline| headline.item.line.abs_diff(line_hint))
})
.map(|headline| headline.item.clone())
.ok_or_else(|| AgdError::new(ErrorCode::ParseFailed, "write produced no target headline"))
}
pub(super) fn mutation(item: Item, diff: String, journal_id: &str) -> Mutation {
Mutation {
schema: MUTATION_SCHEMA.to_string(),
ok: true,
item,
diff,
journal_id: journal_id.to_string(),
repeated: None,
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::fs;
use super::*;
use crate::CaptureDraft;
use crate::write::test_support::{context, first_ref, settings, state_change, tree};
#[test]
fn dry_runs_create_no_directory_lock_journal_or_file() {
let org = tempfile::tempdir().unwrap();
let data = tempfile::tempdir().unwrap();
fs::write(org.path().join("tasks.org"), "* TODO Task\n").unwrap();
let mut settings = settings(org.path());
settings.captures[0].file = Some("new/inbox.org".to_string());
let engine = WriteEngine::new(settings.clone(), data.path().join("journal.jsonl")).unwrap();
let draft = CaptureDraft {
title: "Preview only".to_string(),
..CaptureDraft::default()
};
let preview = engine
.capture("t", &draft, "", &BTreeMap::new(), true, &context())
.unwrap();
assert!(
preview.diff.contains("TODO Preview only"),
"{}",
preview.diff
);
let item_ref = first_ref(&settings);
let done = state_change("DONE");
engine.edit(&item_ref, &done, true, &context()).unwrap();
assert_eq!(tree(org.path()), ["tasks.org"]);
assert!(tree(data.path()).is_empty(), "{:?}", tree(data.path()));
}
}