#![allow(
dead_code,
reason = "AC.2 keeps internal projection writers until all Claude compatibility exports move behind atm-storage-claude."
)]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::config;
use crate::error::AtmError;
use crate::mailbox::atomic;
use crate::mailbox::source::{SourceFile, discover_source_paths, load_source_files};
use crate::schema::InboxMessage;
use crate::schema::inbox_message::SharedAppendPolicy;
use crate::types::{AgentName, TeamName};
const MAX_RECOVERED_MESSAGE_SET_COUNT: usize = 2;
const MAX_RECOVERED_MESSAGE_SET_BYTES: usize = 1024 * 1024;
const MAX_COMPAT_MAILBOX_FILE_BYTES: u64 = 8 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InboxFileFormat {
ClaudeJsonArray,
JsonLines,
Other,
}
pub(crate) fn write_compat_mailbox_projection(
path: &Path,
messages: &[InboxMessage],
) -> Result<(), AtmError> {
let export_policy = export_policy_for_path(path)?;
write_compat_mailbox_projection_with_policy(path, messages, export_policy)
}
pub(crate) fn append_compat_mailbox_message(
path: &Path,
message: &InboxMessage,
) -> Result<(), AtmError> {
let export_policy = export_policy_for_path(path)?;
if inbox_file_format(path) == InboxFileFormat::ClaudeJsonArray {
let existing_messages = if path.exists() {
crate::mailbox::load_compat_mailbox_messages_strict(path)?
} else {
Vec::new()
};
return atomic::write_message_iter(
path,
existing_messages.iter().chain(std::iter::once(message)),
export_policy,
);
}
atomic::append_message(path, message, export_policy)
}
pub(crate) fn append_compat_mailbox_message_set(
path: &Path,
export_policy: SharedAppendPolicy,
messages: &[InboxMessage],
) -> Result<(), AtmError> {
validate_recovered_message_set(messages)?;
validate_compat_mailbox_file_size(path)?;
let mut existing_messages = if path.exists() {
crate::mailbox::load_compat_mailbox_messages_strict(path)?
} else {
Vec::new()
};
existing_messages.extend(messages.iter().cloned());
write_compat_mailbox_projection_with_policy(path, &existing_messages, export_policy)
}
fn write_compat_mailbox_projection_with_policy(
path: &Path,
messages: &[InboxMessage],
export_policy: SharedAppendPolicy,
) -> Result<(), AtmError> {
atomic::write_messages(path, messages, export_policy)
}
pub(crate) fn write_compat_source_projections(source_files: &[SourceFile]) -> Result<(), AtmError> {
let mut export_policy_by_dir = BTreeMap::<PathBuf, SharedAppendPolicy>::new();
for source in source_files {
let config_dir = source
.path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
let export_policy = if let Some(policy) = export_policy_by_dir.get(&config_dir).copied() {
policy
} else {
let policy = export_policy_for_path(&source.path)?;
export_policy_by_dir.insert(config_dir, policy);
policy
};
write_compat_mailbox_projection_with_policy(&source.path, &source.messages, export_policy)?;
}
Ok(())
}
pub(crate) fn export_policy_for_path(path: &Path) -> Result<SharedAppendPolicy, AtmError> {
let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
let atm_authored_body_export_max_bytes = config::load_config(config_dir)?
.map(|config| config.claude_jsonl_body_export_max_bytes)
.unwrap_or_else(|| SharedAppendPolicy::default().atm_authored_body_export_max_bytes);
Ok(SharedAppendPolicy {
atm_authored_body_export_max_bytes,
})
}
pub(crate) fn inbox_file_format(path: &Path) -> InboxFileFormat {
path.extension()
.and_then(|value| value.to_str())
.map(|value| {
if value.eq_ignore_ascii_case("json") {
InboxFileFormat::ClaudeJsonArray
} else if value.eq_ignore_ascii_case("jsonl") {
InboxFileFormat::JsonLines
} else {
InboxFileFormat::Other
}
})
.unwrap_or(InboxFileFormat::Other)
}
fn validate_recovered_message_set(messages: &[InboxMessage]) -> Result<(), AtmError> {
if messages.len() > MAX_RECOVERED_MESSAGE_SET_COUNT {
return Err(AtmError::new_with_code(
crate::error_codes::AtmErrorCode::MailboxRecoveredMessageSetTooLarge,
crate::error::AtmErrorKind::Validation,
format!(
"recovered Claude logical message set exceeded {MAX_RECOVERED_MESSAGE_SET_COUNT} messages"
),
)
.with_recovery(
"Keep recovered Claude compatibility delivery to the original message plus one optional companion error message before retrying.",
));
}
let encoded = serde_json::to_vec(messages).map_err(|error| {
AtmError::mailbox_write("failed to measure recovered Claude logical message set size")
.with_source(error)
})?;
if encoded.len() > MAX_RECOVERED_MESSAGE_SET_BYTES {
return Err(AtmError::new_with_code(
crate::error_codes::AtmErrorCode::MailboxRecoveredMessageSetTooLarge,
crate::error::AtmErrorKind::Validation,
format!(
"recovered Claude logical message set exceeded {MAX_RECOVERED_MESSAGE_SET_BYTES} bytes"
),
)
.with_recovery(
"Reduce the recovered Claude message bodies or attachments before retrying compatibility export.",
));
}
Ok(())
}
fn validate_compat_mailbox_file_size(path: &Path) -> Result<(), AtmError> {
let Ok(metadata) = std::fs::metadata(path) else {
return Ok(());
};
if metadata.len() > MAX_COMPAT_MAILBOX_FILE_BYTES {
return Err(AtmError::mailbox_read(format!(
"compatibility inbox {} exceeded the {MAX_COMPAT_MAILBOX_FILE_BYTES}-byte recovered export ceiling",
path.display()
))
.with_recovery(
"Run the explicit repair/rebuild inbox projection path or reduce retained inbox size before retrying recovered Claude compatibility delivery.",
));
}
Ok(())
}
pub(crate) fn load_source_projections(
home_dir: &Path,
team: &TeamName,
agent: &AgentName,
) -> Result<Vec<SourceFile>, AtmError> {
let source_paths = discover_source_paths(home_dir, team, agent)?;
load_source_files(&source_paths)
}
#[cfg(test)]
mod tests {
use tempfile::tempdir;
use super::{
append_compat_mailbox_message, append_compat_mailbox_message_set,
write_compat_mailbox_projection, write_compat_source_projections,
};
use crate::mailbox::load_compat_mailbox_messages;
use crate::mailbox::source::SourceFile;
use crate::schema::inbox_message::SharedAppendPolicy;
use crate::schema::{AtmMessageId, InboxMessage};
use crate::test_support::{TEST_QA, TEST_SENDER};
use crate::types::{AgentName, IsoTimestamp};
#[test]
fn write_compat_mailbox_projection_rewrites_mailbox_array_with_only_new_messages() {
let tempdir = tempdir().expect("tempdir");
let path = tempdir.path().join(format!("{TEST_SENDER}.json"));
std::fs::write(&path, "{\"stale\":true}\n").expect("seed mailbox");
let messages = vec![
sample_message(TEST_SENDER, "first replacement"),
sample_message(TEST_QA, "second replacement"),
];
write_compat_mailbox_projection(&path, &messages).expect("commit mailbox");
let raw = std::fs::read_to_string(&path).expect("mailbox contents");
assert!(!raw.contains("stale"));
let encoded: Vec<serde_json::Value> = serde_json::from_str(&raw).expect("json array");
assert_eq!(encoded.len(), 2);
assert!(raw.ends_with('\n'));
let read_back = load_compat_mailbox_messages(&path).expect("read mailbox");
assert_eq!(read_back.len(), messages.len());
assert_eq!(read_back[0].text, messages[0].text);
assert_eq!(read_back[1].text, messages[1].text);
assert!(
read_back
.iter()
.all(|message| message.source_team.is_none())
);
assert!(
read_back.iter().all(
|message| message.pending_ack_at.is_none() && message.acknowledged_at.is_none()
)
);
}
#[test]
fn write_compat_mailbox_projection_exports_retrieval_stub_when_config_cap_is_zero() {
let tempdir = tempdir().expect("tempdir");
std::fs::write(
tempdir.path().join(".atm.toml"),
"[atm]\nclaude_jsonl_body_export_max_bytes = 0\n",
)
.expect("config");
let path = tempdir.path().join(format!("{TEST_SENDER}.json"));
let mut message = sample_message(TEST_SENDER, "full body retained elsewhere");
let message_id = message.message_id.expect("message id");
message.summary = Some("stub summary".to_string());
write_compat_mailbox_projection(&path, std::slice::from_ref(&message))
.expect("commit mailbox");
let raw = std::fs::read_to_string(&path).expect("mailbox contents");
let encoded: Vec<serde_json::Value> = serde_json::from_str(&raw).expect("json array");
assert_eq!(
encoded[0]["text"],
serde_json::Value::String(format!("atm read --message-id {message_id}"))
);
assert_eq!(
encoded[0]["summary"],
serde_json::Value::String("stub summary".into())
);
}
#[test]
fn append_compat_mailbox_message_writes_jsonl_records() {
let tempdir = tempdir().expect("tempdir");
let path = tempdir.path().join(format!("{TEST_SENDER}.jsonl"));
let first = sample_message(TEST_SENDER, "first line");
let second = sample_message(TEST_QA, "second line");
append_compat_mailbox_message(&path, &first).expect("append first");
append_compat_mailbox_message(&path, &second).expect("append second");
let raw = std::fs::read_to_string(&path).expect("mailbox contents");
assert_eq!(raw.lines().count(), 2);
let read_back = load_compat_mailbox_messages(&path).expect("read mailbox");
assert_eq!(read_back.len(), 2);
assert_eq!(read_back[0].text, first.text);
assert_eq!(read_back[1].text, second.text);
}
#[test]
fn append_compat_mailbox_message_creates_current_claude_json_array_mailbox() {
let tempdir = tempdir().expect("tempdir");
let path = tempdir.path().join(format!("{TEST_SENDER}.json"));
let first = sample_message(TEST_SENDER, "first array entry");
append_compat_mailbox_message(&path, &first).expect("append first");
let raw = std::fs::read_to_string(&path).expect("mailbox contents");
let encoded: Vec<serde_json::Value> = serde_json::from_str(&raw).expect("json array");
assert_eq!(encoded.len(), 1);
assert_eq!(
encoded[0]["text"],
serde_json::Value::String(first.text.clone())
);
let read_back = load_compat_mailbox_messages(&path).expect("read mailbox");
assert_eq!(read_back.len(), 1);
assert_eq!(read_back[0].text, first.text);
}
#[test]
fn append_compat_mailbox_message_rewrites_current_claude_json_array_mailbox() {
let tempdir = tempdir().expect("tempdir");
let path = tempdir.path().join(format!("{TEST_SENDER}.json"));
let first = sample_message(TEST_SENDER, "first array entry");
let second = sample_message(TEST_QA, "second array entry");
write_compat_mailbox_projection(&path, std::slice::from_ref(&first))
.expect("seed array mailbox");
append_compat_mailbox_message(&path, &second).expect("append second");
let raw = std::fs::read_to_string(&path).expect("mailbox contents");
let encoded: Vec<serde_json::Value> = serde_json::from_str(&raw).expect("json array");
assert_eq!(encoded.len(), 2);
let read_back = load_compat_mailbox_messages(&path).expect("read mailbox");
assert_eq!(read_back.len(), 2);
assert_eq!(read_back[0].text, first.text);
assert_eq!(read_back[1].text, second.text);
}
#[test]
fn append_compat_mailbox_message_exports_retrieval_stub_when_config_cap_is_zero() {
let tempdir = tempdir().expect("tempdir");
std::fs::write(
tempdir.path().join(".atm.toml"),
"[atm]\nclaude_jsonl_body_export_max_bytes = 0\n",
)
.expect("config");
let path = tempdir.path().join(format!("{TEST_SENDER}.jsonl"));
let mut message = sample_message(TEST_SENDER, "full body retained elsewhere");
let message_id = message.message_id.expect("message id");
message.summary = Some("stub summary".to_string());
append_compat_mailbox_message(&path, &message).expect("append message");
let raw = std::fs::read_to_string(&path).expect("mailbox contents");
let first_line = raw.lines().next().expect("jsonl record");
let encoded: serde_json::Value = serde_json::from_str(first_line).expect("json object");
assert_eq!(
encoded["text"],
serde_json::Value::String(format!("atm read --message-id {message_id}"))
);
assert_eq!(
encoded["summary"],
serde_json::Value::String("stub summary".into())
);
}
#[test]
fn append_compat_mailbox_message_set_appends_existing_messages_atomically() {
let tempdir = tempdir().expect("tempdir");
let path = tempdir.path().join(format!("{TEST_SENDER}.jsonl"));
let existing = sample_message(TEST_SENDER, "existing");
let appended = [
sample_message(TEST_QA, "new first"),
sample_message(TEST_SENDER, "new second"),
];
let export_policy = SharedAppendPolicy::default();
append_compat_mailbox_message(&path, &existing).expect("seed existing");
append_compat_mailbox_message_set(&path, export_policy, &appended).expect("append set");
let read_back = load_compat_mailbox_messages(&path).expect("read mailbox");
assert_eq!(read_back.len(), 3);
assert_eq!(read_back[0].text, existing.text);
assert_eq!(read_back[1].text, appended[0].text);
assert_eq!(read_back[2].text, appended[1].text);
}
#[test]
fn append_compat_mailbox_message_set_rejects_more_than_two_messages() {
let tempdir = tempdir().expect("tempdir");
let path = tempdir.path().join(format!("{TEST_SENDER}.jsonl"));
let appended = [
sample_message(TEST_QA, "new first"),
sample_message(TEST_SENDER, "new second"),
sample_message(TEST_QA, "new third"),
];
let error =
append_compat_mailbox_message_set(&path, SharedAppendPolicy::default(), &appended)
.expect_err("reject oversized recovered message set");
assert!(error.is_validation());
assert!(error.message.contains("exceeded 2 messages"));
}
#[test]
fn write_compat_source_projections_commits_each_source_path() {
let tempdir = tempdir().expect("tempdir");
let left_path = tempdir.path().join(format!("{TEST_SENDER}.json"));
let right_path = tempdir.path().join(format!("{TEST_QA}.json"));
let left_messages = vec![sample_message(TEST_SENDER, "left message")];
let right_messages = vec![
sample_message(TEST_SENDER, "right first"),
sample_message(TEST_QA, "right second"),
];
write_compat_source_projections(&[
SourceFile {
path: left_path.clone(),
messages: left_messages.clone(),
},
SourceFile {
path: right_path.clone(),
messages: right_messages.clone(),
},
])
.expect("commit source files");
let left = load_compat_mailbox_messages(&left_path).expect("left inbox");
let right = load_compat_mailbox_messages(&right_path).expect("right inbox");
assert_eq!(left.len(), left_messages.len());
assert_eq!(right.len(), right_messages.len());
assert_eq!(left[0].text, left_messages[0].text);
assert_eq!(right[0].text, right_messages[0].text);
assert_eq!(right[1].text, right_messages[1].text);
assert!(left.iter().all(|message| message.source_team.is_none()));
assert!(right.iter().all(|message| message.source_team.is_none()));
}
#[test]
fn write_compat_source_projections_stops_after_first_write_error() {
let tempdir = tempdir().expect("tempdir");
let first_path = tempdir.path().join("first.json");
let invalid_path = tempdir.path().to_path_buf();
let later_path = tempdir.path().join("later.json");
let error = write_compat_source_projections(&[
SourceFile {
path: first_path.clone(),
messages: vec![sample_message(TEST_SENDER, "first")],
},
SourceFile {
path: invalid_path,
messages: vec![sample_message(TEST_QA, "broken")],
},
SourceFile {
path: later_path.clone(),
messages: vec![sample_message(TEST_SENDER, "later")],
},
])
.expect_err("write failure");
assert!(error.is_mailbox_write());
assert_eq!(
load_compat_mailbox_messages(&first_path)
.expect("first inbox")
.len(),
1
);
assert!(!later_path.exists());
}
fn sample_message(from: &str, text: &str) -> InboxMessage {
let message_id = AtmMessageId::new();
InboxMessage {
from: from.parse::<AgentName>().expect("agent name"),
text: text.to_string(),
timestamp: IsoTimestamp::now(),
read: false,
source_team: None,
summary: None,
message_id: Some(message_id),
pending_ack_at: None,
acknowledged_at: None,
acknowledges_message_id: None,
parent_message_id: None,
thread_mode: None,
expires_at: None,
task_id: None,
extra: serde_json::Map::new(),
}
}
}