use anyhow::{Context, Result};
use std::path::Path;
use std::process::Command;
use crate::{component, frontmatter, snapshot, write};
fn format_source_annotation(source: &Path, action: &str) -> String {
let timestamp = Command::new("date")
.args(["-u", "+%Y-%m-%dT%H:%M:%SZ"])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
format!(
"\n> **[{} from {}]** ({})\n>\n",
action.to_uppercase(),
source.display(),
timestamp,
)
}
pub fn run(source: &Path, target: &Path, component_name: Option<&str>) -> Result<()> {
if !source.exists() {
anyhow::bail!("source file not found: {}", source.display());
}
if !target.exists() {
anyhow::bail!("target file not found: {}", target.display());
}
let source_content = std::fs::read_to_string(source)
.with_context(|| format!("failed to read {}", source.display()))?;
let target_content = std::fs::read_to_string(target)
.with_context(|| format!("failed to read {}", target.display()))?;
let comp_name = component_name.unwrap_or("exchange");
let components = component::parse(&source_content)
.context("failed to parse components in source")?;
let exchange = components.iter().find(|c| c.name == comp_name);
let Some(exchange) = exchange else {
anyhow::bail!("component '{}' not found in {}", comp_name, source.display());
};
let exchange_content = exchange.content(&source_content);
if exchange_content.trim().is_empty() {
anyhow::bail!("component '{}' is empty in {}", comp_name, source.display());
}
let (extracted, remaining) = split_last_entry(exchange_content);
if extracted.trim().is_empty() {
anyhow::bail!("no exchange entry found to extract");
}
let new_source = exchange.replace_content(&source_content, &remaining);
write::atomic_write_pub(source, &new_source)?;
snapshot::save(source, &new_source)?;
let annotation = format_source_annotation(source, "Extract");
let annotated_content = format!("{}{}", annotation, extracted.trim_start());
let target_components = component::parse(&target_content)
.context("failed to parse components in target")?;
let target_exchange = target_components.iter().find(|c| c.name == comp_name);
let new_target = if let Some(tc) = target_exchange {
let existing = tc.content(&target_content);
let appended = format!("{}{}", existing.trim_end(), if existing.trim().is_empty() { "\n" } else { "\n\n" });
tc.replace_content(&target_content, &format!("{}{}\n", appended.trim_end(), annotated_content.trim_end()))
} else {
format!("{}\n{}\n", target_content.trim_end(), annotated_content.trim_end())
};
write::atomic_write_pub(target, &new_target)?;
snapshot::save(target, &new_target)?;
eprintln!(
"[extract] Moved last entry from {}:{} → {}:{}",
source.display(), comp_name, target.display(), comp_name
);
Ok(())
}
fn split_last_entry(content: &str) -> (String, String) {
let mut last_header_pos = None;
for (i, _) in content.match_indices("### Re:") {
last_header_pos = Some(i);
}
match last_header_pos {
Some(pos) => {
let remaining = &content[..pos];
let extracted = &content[pos..];
(extracted.to_string(), remaining.to_string())
}
None => {
(content.to_string(), String::new())
}
}
}
pub fn transfer(source: &Path, target: &Path, component_name: &str) -> Result<()> {
if !source.exists() {
anyhow::bail!("source file not found: {}", source.display());
}
if !target.exists() {
let source_content = std::fs::read_to_string(source)
.with_context(|| format!("failed to read {}", source.display()))?;
let title = target
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Untitled Session");
let session_id = uuid::Uuid::new_v4();
let agent = frontmatter::parse(&source_content)
.ok()
.and_then(|(fm, _)| fm.agent.clone())
.unwrap_or_else(|| "claude".to_string());
let target_content = format!(
"---\nagent_doc_session: {}\nagent: {}\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n# {}\n\n## Exchange\n\n<!-- agent:exchange -->\n<!-- /agent:exchange -->\n",
session_id, agent, title
);
if let Some(parent) = target.parent()
&& !parent.exists()
{
std::fs::create_dir_all(parent)?;
}
std::fs::write(target, &target_content)?;
snapshot::save(target, &target_content)?;
eprintln!("[transfer] Auto-created {} (template)", target.display());
}
let source_content = std::fs::read_to_string(source)
.with_context(|| format!("failed to read {}", source.display()))?;
let target_content = std::fs::read_to_string(target)
.with_context(|| format!("failed to read {}", target.display()))?;
let components = component::parse(&source_content)
.context("failed to parse components in source")?;
let comp = components.iter().find(|c| c.name == component_name);
let Some(comp) = comp else {
anyhow::bail!("component '{}' not found in {}", component_name, source.display());
};
let content = comp.content(&source_content);
if content.trim().is_empty() {
anyhow::bail!("component '{}' is empty in {}", component_name, source.display());
}
let new_source = comp.replace_content(&source_content, "\n");
write::atomic_write_pub(source, &new_source)?;
snapshot::save(source, &new_source)?;
let annotation = format_source_annotation(source, "Transfer");
let annotated_content = format!("{}{}", annotation, content.trim_start());
let target_components = component::parse(&target_content)
.context("failed to parse components in target")?;
let target_comp = target_components.iter().find(|c| c.name == component_name);
let new_target = if let Some(tc) = target_comp {
let existing = tc.content(&target_content);
tc.replace_content(&target_content, &format!("{}{}\n", existing, annotated_content.trim_end()))
} else {
format!("{}\n{}\n", target_content.trim_end(), annotated_content.trim_end())
};
write::atomic_write_pub(target, &new_target)?;
snapshot::save(target, &new_target)?;
if component_name != "pending" {
let source_refreshed = std::fs::read_to_string(source)?;
let target_refreshed = std::fs::read_to_string(target)?;
let source_comps = component::parse(&source_refreshed).unwrap_or_default();
let target_comps = component::parse(&target_refreshed).unwrap_or_default();
if let Some(source_pending) = source_comps.iter().find(|c| c.name == "pending")
&& let Some(target_pending) = target_comps.iter().find(|c| c.name == "pending")
{
let pending_content = source_pending.content(&source_refreshed);
if !pending_content.trim().is_empty() {
let existing = target_pending.content(&target_refreshed);
let merged = format!("{}{}\n", existing, pending_content.trim_end());
let new_target_with_pending = target_pending.replace_content(&target_refreshed, &merged);
write::atomic_write_pub(target, &new_target_with_pending)?;
snapshot::save(target, &new_target_with_pending)?;
let new_source_cleared = source_pending.replace_content(&source_refreshed, "\n");
write::atomic_write_pub(source, &new_source_cleared)?;
snapshot::save(source, &new_source_cleared)?;
eprintln!("[transfer] Also transferred 'pending' component");
}
}
}
crate::git::commit(target)?;
eprintln!(
"[transfer] Moved component '{}' from {} → {}",
component_name, source.display(), target.display()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_last_entry_single_block() {
let content = "### Re: Question\n\nAnswer here.\n";
let (extracted, remaining) = split_last_entry(content);
assert_eq!(extracted, "### Re: Question\n\nAnswer here.\n");
assert_eq!(remaining, "");
}
#[test]
fn split_last_entry_multiple_blocks() {
let content = "### Re: First\n\nFirst answer.\n\n### Re: Second\n\nSecond answer.\n";
let (extracted, remaining) = split_last_entry(content);
assert_eq!(extracted, "### Re: Second\n\nSecond answer.\n");
assert_eq!(remaining, "### Re: First\n\nFirst answer.\n\n");
}
#[test]
fn split_last_entry_no_headers() {
let content = "Just some text without headers.\n";
let (extracted, remaining) = split_last_entry(content);
assert_eq!(extracted, "Just some text without headers.\n");
assert_eq!(remaining, "");
}
#[test]
fn pending_merge_appends_source_items_to_target() {
let source_pending = "- [ ] Item from source\n- [ ] Another source item\n";
let target_pending = "- [ ] Existing target item\n";
let merged = format!("{}{}\n", target_pending, source_pending.trim_end());
assert!(merged.contains("Existing target item"), "target items preserved");
assert!(merged.contains("Item from source"), "source items appended");
assert!(merged.contains("Another source item"), "all source items appended");
}
#[test]
fn pending_merge_skips_empty_source() {
let source_pending = "\n";
assert!(source_pending.trim().is_empty(), "empty source should be skipped");
}
}